355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import {
|
||
SEARCH_SOURCE_MODE_OPTIONS,
|
||
searchSourceModeLabel,
|
||
type SearchSourceMode,
|
||
} from "@/lib/search/source-mode";
|
||
|
||
export type ConnectionPreset = "sync" | "api" | "hybrid" | "custom";
|
||
|
||
export interface ThreadsConnectionSettingsData {
|
||
searchViaApi?: boolean;
|
||
searchSourceMode?: SearchSourceMode;
|
||
publishViaApi?: boolean;
|
||
devMode?: boolean;
|
||
scrapeReplies?: boolean;
|
||
repliesPerPost?: number;
|
||
publishHeaded?: boolean;
|
||
playwrightDebug?: boolean;
|
||
}
|
||
|
||
interface ThreadsConnectionSettingsProps {
|
||
settings: ThreadsConnectionSettingsData;
|
||
accountName?: string | null;
|
||
onChange: (patch: Partial<ThreadsConnectionSettingsData>) => void;
|
||
}
|
||
|
||
const PRIMARY_SOURCE_MODES: SearchSourceMode[] = [
|
||
"mixed",
|
||
"threads",
|
||
"brave",
|
||
"crawler",
|
||
];
|
||
|
||
const COMBO_SOURCE_MODES: SearchSourceMode[] = [
|
||
"threads_brave",
|
||
"threads_crawler",
|
||
"brave_crawler",
|
||
];
|
||
|
||
function detectPreset(data: ThreadsConnectionSettingsData): ConnectionPreset {
|
||
const searchApi = data.searchViaApi ?? false;
|
||
const publishApi = data.publishViaApi ?? false;
|
||
const dev = data.devMode ?? false;
|
||
|
||
if (searchApi && publishApi && !dev) return "api";
|
||
if (!searchApi && !publishApi && dev) return "sync";
|
||
if (searchApi && dev) return "hybrid";
|
||
return "custom";
|
||
}
|
||
|
||
function presetPatch(preset: ConnectionPreset): Partial<ThreadsConnectionSettingsData> {
|
||
switch (preset) {
|
||
case "api":
|
||
return {
|
||
searchViaApi: true,
|
||
publishViaApi: true,
|
||
devMode: false,
|
||
scrapeReplies: false,
|
||
searchSourceMode: "threads",
|
||
};
|
||
case "sync":
|
||
return {
|
||
searchViaApi: false,
|
||
publishViaApi: false,
|
||
devMode: true,
|
||
scrapeReplies: true,
|
||
searchSourceMode: "crawler",
|
||
};
|
||
case "hybrid":
|
||
return {
|
||
searchViaApi: true,
|
||
publishViaApi: false,
|
||
devMode: true,
|
||
scrapeReplies: true,
|
||
searchSourceMode: "mixed",
|
||
};
|
||
default:
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function sourceModeHint(mode: SearchSourceMode): string {
|
||
return SEARCH_SOURCE_MODE_OPTIONS.find((o) => o.value === mode)?.hint ?? "";
|
||
}
|
||
|
||
function FlowSummary({ settings }: { settings: ThreadsConnectionSettingsData }) {
|
||
const mode = settings.searchSourceMode ?? "mixed";
|
||
|
||
const lines = useMemo(() => {
|
||
const sourceLine = `海巡來源:${searchSourceModeLabel(mode)}(${sourceModeHint(mode)})`;
|
||
|
||
const search = settings.searchViaApi
|
||
? "Threads API:已開啟(需 OAuth + keyword search 權限)"
|
||
: settings.devMode
|
||
? "Threads API:關閉,改走下方選定的來源"
|
||
: "Threads API:關閉";
|
||
|
||
const replies =
|
||
settings.scrapeReplies && settings.devMode
|
||
? `留言:瀏覽器爬取(每篇最多 ${settings.repliesPerPost ?? 10} 則)`
|
||
: settings.scrapeReplies
|
||
? "留言:已開啟但 devMode 關閉,實際不會爬留言"
|
||
: "留言:不抓取(僅用貼文與互動數)";
|
||
|
||
const publish = settings.publishViaApi
|
||
? "發文:Meta API 優先,失敗時改瀏覽器"
|
||
: "發文:Playwright 瀏覽器(需 Chrome 同步 session)";
|
||
|
||
return [sourceLine, search, replies, publish];
|
||
}, [settings, mode]);
|
||
|
||
return (
|
||
<div className="rounded-lg border border-border bg-muted/40 px-3.5 py-3 text-xs leading-relaxed text-muted-foreground">
|
||
<p className="mb-2 font-semibold text-foreground">目前流程預覽</p>
|
||
<ul className="list-inside list-disc space-y-1">
|
||
{lines.map((line) => (
|
||
<li key={line}>{line}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ThreadsConnectionSettings({
|
||
settings,
|
||
accountName,
|
||
onChange,
|
||
}: ThreadsConnectionSettingsProps) {
|
||
const [preset, setPreset] = useState<ConnectionPreset>("sync");
|
||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||
const [showComboModes, setShowComboModes] = useState(false);
|
||
|
||
const currentMode = settings.searchSourceMode ?? "mixed";
|
||
|
||
useEffect(() => {
|
||
setPreset(detectPreset(settings));
|
||
setShowComboModes(COMBO_SOURCE_MODES.includes(currentMode));
|
||
}, [settings, currentMode]);
|
||
|
||
function applyPreset(next: ConnectionPreset) {
|
||
setPreset(next);
|
||
if (next !== "custom") {
|
||
onChange(presetPatch(next));
|
||
}
|
||
}
|
||
|
||
function applySourceMode(mode: SearchSourceMode) {
|
||
setPreset("custom");
|
||
const patch: Partial<ThreadsConnectionSettingsData> = { searchSourceMode: mode };
|
||
|
||
if (mode === "threads" || mode === "threads_brave" || mode === "threads_crawler") {
|
||
patch.searchViaApi = true;
|
||
}
|
||
if (mode === "crawler" || mode === "threads_crawler" || mode === "brave_crawler") {
|
||
patch.devMode = true;
|
||
}
|
||
if (mode === "mixed") {
|
||
patch.searchViaApi = true;
|
||
patch.devMode = true;
|
||
}
|
||
|
||
onChange(patch);
|
||
setShowComboModes(COMBO_SOURCE_MODES.includes(mode));
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>海巡搜尋來源</CardTitle>
|
||
<CardDescription>
|
||
選擇要用哪個 provider,或混合模式。Brave 需設定 BRAVE_SEARCH_API_KEY;爬蟲需 Chrome
|
||
同步。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex flex-wrap gap-2">
|
||
{SEARCH_SOURCE_MODE_OPTIONS.filter((o) =>
|
||
PRIMARY_SOURCE_MODES.includes(o.value)
|
||
).map((option) => (
|
||
<Button
|
||
key={option.value}
|
||
type="button"
|
||
variant={currentMode === option.value ? "default" : "outline"}
|
||
className="h-auto flex-col items-start gap-0.5 px-3 py-2"
|
||
onClick={() => applySourceMode(option.value)}
|
||
>
|
||
<span className="text-sm font-semibold">{option.label}</span>
|
||
<span className="text-[10px] font-normal opacity-80">{option.hint}</span>
|
||
</Button>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
||
onClick={() => setShowComboModes((v) => !v)}
|
||
>
|
||
{showComboModes ? "收合組合模式" : "顯示組合模式(API + Brave、API + 爬蟲…)"}
|
||
</button>
|
||
|
||
{showComboModes && (
|
||
<div className="flex flex-wrap gap-2">
|
||
{SEARCH_SOURCE_MODE_OPTIONS.filter((o) =>
|
||
COMBO_SOURCE_MODES.includes(o.value)
|
||
).map((option) => (
|
||
<Button
|
||
key={option.value}
|
||
type="button"
|
||
variant={currentMode === option.value ? "default" : "outline"}
|
||
size="sm"
|
||
className="h-auto flex-col items-start gap-0.5 px-3 py-2"
|
||
onClick={() => applySourceMode(option.value)}
|
||
>
|
||
<span className="text-sm font-semibold">{option.label}</span>
|
||
<span className="text-[10px] font-normal opacity-80">{option.hint}</span>
|
||
</Button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>連線預設</CardTitle>
|
||
<CardDescription>
|
||
{accountName
|
||
? `目前帳號「${accountName}」的抓取策略。側欄切換帳號會載入各自的設定。`
|
||
: "請先在側欄建立並選擇經營帳號。"}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex flex-wrap gap-2">
|
||
{(
|
||
[
|
||
["sync", "Chrome 同步", "全爬蟲:海巡 + 留言 + 發文"],
|
||
["api", "API Key 優先", "全官方 API:不爬留言"],
|
||
["hybrid", "混合模式", "API 海巡 + 瀏覽器留言/發文"],
|
||
["custom", "自訂", "手動調整下方開關"],
|
||
] as const
|
||
).map(([value, label, hint]) => (
|
||
<Button
|
||
key={value}
|
||
type="button"
|
||
variant={preset === value ? "default" : "outline"}
|
||
className="h-auto flex-col items-start gap-0.5 px-3 py-2"
|
||
onClick={() => applyPreset(value)}
|
||
>
|
||
<span className="text-sm font-semibold">{label}</span>
|
||
<span className="text-[10px] font-normal opacity-80">{hint}</span>
|
||
</Button>
|
||
))}
|
||
</div>
|
||
<FlowSummary settings={settings} />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<button
|
||
type="button"
|
||
className="flex w-full items-center justify-between text-left"
|
||
onClick={() => setShowAdvanced((v) => !v)}
|
||
>
|
||
<div>
|
||
<CardTitle>進階開關</CardTitle>
|
||
<CardDescription>直接控制底層行為。變更後會切換為「自訂」預設。</CardDescription>
|
||
</div>
|
||
{showAdvanced ? (
|
||
<ChevronUp className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||
) : (
|
||
<ChevronDown className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||
)}
|
||
</button>
|
||
</CardHeader>
|
||
{showAdvanced && (
|
||
<CardContent className="space-y-3">
|
||
{(
|
||
[
|
||
{
|
||
key: "searchViaApi" as const,
|
||
title: "海巡走官方 API",
|
||
desc: "關閉則用 Playwright 瀏覽器搜尋",
|
||
},
|
||
{
|
||
key: "publishViaApi" as const,
|
||
title: "發文走官方 API",
|
||
desc: "關閉則只用瀏覽器發文",
|
||
},
|
||
{
|
||
key: "devMode" as const,
|
||
title: "瀏覽器模式(devMode)",
|
||
desc: "開啟才允許瀏覽器海巡與爬留言",
|
||
},
|
||
{
|
||
key: "scrapeReplies" as const,
|
||
title: "抓取他人貼文留言",
|
||
desc: "需 devMode 開啟才會實際執行",
|
||
},
|
||
{
|
||
key: "publishHeaded" as const,
|
||
title: "發文時顯示瀏覽器視窗",
|
||
desc: "除錯用,server 上通常保持關閉",
|
||
},
|
||
{
|
||
key: "playwrightDebug" as const,
|
||
title: "Playwright 除錯截圖",
|
||
desc: "卡住時保留瀏覽器截圖",
|
||
},
|
||
] as const
|
||
).map((item) => (
|
||
<div
|
||
key={item.key}
|
||
className="flex items-center justify-between rounded-lg border border-border bg-muted/50 px-3.5 py-3"
|
||
>
|
||
<div>
|
||
<p className="text-sm font-semibold">{item.title}</p>
|
||
<p className="mt-0.5 text-xs text-muted-foreground">{item.desc}</p>
|
||
</div>
|
||
<Switch
|
||
checked={settings[item.key] ?? false}
|
||
onCheckedChange={(checked) => {
|
||
setPreset("custom");
|
||
onChange({ [item.key]: checked });
|
||
}}
|
||
/>
|
||
</div>
|
||
))}
|
||
<div className="space-y-2 pt-1">
|
||
<Label>每篇熱門文讀取幾則留言</Label>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={30}
|
||
value={settings.repliesPerPost ?? 10}
|
||
onChange={(e) =>
|
||
onChange({ repliesPerPost: parseInt(e.target.value, 10) || 10 })
|
||
}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
);
|
||
} |