haixunMaster/components/settings/threads-connection-settings...

355 lines
12 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
"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>
providerBrave 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>
);
}