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

355 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}