"use client"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { ArrowLeft, Loader2, MessageSquare, Radar, RefreshCw, Save, Sparkles, Table2, } from "lucide-react"; import { ResearchMapView } from "@/components/research-map-view"; import { SuggestedTagsPicker } from "@/components/suggested-tags-picker"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { FeatureGate } from "@/components/layout/feature-gate"; import { PageHeader } from "@/components/layout/page-header"; import { JobProgressPanel } from "@/components/job-progress-panel"; import { openRefine, registerApplyCallback, syncResearchMap } from "@/lib/refine-session/store"; import { reconcileSelectedTags } from "@/lib/services/preserve-selected-tags"; import { Textarea } from "@/components/ui/textarea"; import { InlineAlert } from "@/components/ui/inline-alert"; import { notify } from "@/lib/notifications/store"; import type { ActionFeedback } from "@/lib/use-action-feedback"; import { useActionFeedback } from "@/lib/use-action-feedback"; import type { ResearchMap } from "@/lib/types/research"; import { BrandProductPicker } from "@/components/product-profile/brand-product-picker"; import { hasProductContext, summarizeProductContext } from "@/lib/types/product-context"; import { TOPIC_GOAL_LABELS, isPlacementGoal } from "@/lib/types/topic-goal"; import { parseFetchJson } from "@/lib/utils"; import { parseStyle8DProfile } from "@/lib/types/style-profile"; interface Topic { id: string; label: string; query: string; brief: string | null; productContext: string | null; brandProfileId?: string | null; productProfileId?: string | null; topicGoal: string; researchMap: string | null; selectedTags: string | null; active: boolean; } function parseResearchMap(raw: string | null): ResearchMap | null { if (!raw) return null; try { return JSON.parse(raw) as ResearchMap; } catch { return null; } } function parseTags(raw: string | null): string[] { if (!raw) return []; try { const parsed = JSON.parse(raw) as unknown; return Array.isArray(parsed) ? parsed.filter((t): t is string => typeof t === "string") : []; } catch { return []; } } export default function TopicDetailPage() { const params = useParams(); const router = useRouter(); const topicId = params.topicId as string; const [topic, setTopic] = useState(null); const [brief, setBrief] = useState(""); const [productContext, setProductContext] = useState(""); const [brandProfileId, setBrandProfileId] = useState(null); const [productProfileId, setProductProfileId] = useState(null); const [researchMap, setResearchMap] = useState(null); const [selectedTags, setSelectedTags] = useState([]); const [analyzing, setAnalyzing] = useState(false); const [analyzeElapsed, setAnalyzeElapsed] = useState(0); const [analyzeError, setAnalyzeError] = useState(null); const [analyzeProgress, setAnalyzeProgress] = useState(null); const [saving, setSaving] = useState(false); const [analyzingTags, setAnalyzingTags] = useState(false); const [scanning, setScanning] = useState(false); const [scanProgress, setScanProgress] = useState(null); const [scanProgressDetail, setScanProgressDetail] = useState(null); const [showScanSummary, setShowScanSummary] = useState(false); const [activeScanJobId, setActiveScanJobId] = useState(null); const [cancellingScan, setCancellingScan] = useState(false); const [generating, setGenerating] = useState(false); const [lastScanId, setLastScanId] = useState(null); const [style8DReady, setStyle8DReady] = useState(false); const { feedback, clearFeedback, showError, showSuccess, showWarning } = useActionFeedback(); const [brandFeedback, setBrandFeedback] = useState(null); const [tagsFeedback, setTagsFeedback] = useState(null); const [scanFeedback, setScanFeedback] = useState(null); const load = useCallback(async () => { const [topicsRes, scansRes, accountsRes] = await Promise.all([ fetch("/api/topics"), fetch("/api/scans"), fetch("/api/accounts"), ]); const topicsData = await topicsRes.json(); const scansData = await scansRes.json(); const accountsData = (await accountsRes.json()) as { activeAccountId?: string | null; accounts?: Array<{ id: string; styleProfile?: string | null }>; }; const activeAccount = accountsData.accounts?.find( (row) => row.id === accountsData.activeAccountId ) ?? accountsData.accounts?.[0]; setStyle8DReady(!!parseStyle8DProfile(activeAccount?.styleProfile)); const found = (topicsData.topics as Topic[]).find((t) => t.id === topicId); if (!found) return; setTopic(found); setBrief(found.brief ?? ""); setProductContext(found.productContext ?? ""); setBrandProfileId(found.brandProfileId ?? null); setProductProfileId(found.productProfileId ?? null); setResearchMap(parseResearchMap(found.researchMap)); setSelectedTags(parseTags(found.selectedTags)); const latestScan = (scansData.scans as Array<{ id: string; topicId: string }>).find( (s) => s.topicId === topicId ); if (latestScan) setLastScanId(latestScan.id); }, [topicId]); const syncActiveJobs = useCallback(async () => { try { const res = await fetch(`/api/jobs?topicId=${topicId}&active=1`); const data = await parseFetchJson<{ jobs?: Array<{ id: string; type: string; progress?: string | null; progressDetail?: string | null; }> }>(res); if (!res.ok) return; const active = data.jobs ?? []; const analyzeJob = active.find((j) => j.type === "analyze-topic"); const scanJob = active.find((j) => j.type === "scan"); setAnalyzing(!!analyzeJob); setAnalyzeProgress(analyzeJob?.progress ?? null); setScanning(!!scanJob); setScanProgress(scanJob?.progress ?? null); setScanProgressDetail(scanJob?.progressDetail ?? null); setActiveScanJobId(scanJob?.id ?? null); } catch { // 背景輪詢失敗時略過,避免整頁崩潰 } }, [topicId]); useEffect(() => { if (!topic || !researchMap) return; syncResearchMap(topicId, researchMap, topic.label); }, [topicId, topic, researchMap]); useEffect(() => { return registerApplyCallback(topicId, (map) => { setSelectedTags((prev) => { const reconciled = reconcileSelectedTags(prev, map, { label: topic?.label ?? "", query: topic?.query ?? "", brief: topic?.brief, }); setResearchMap(reconciled.researchMap); void fetch("/api/topics", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: topicId, researchMap: reconciled.researchMap, selectedTags: reconciled.selectedTags, }), }); return reconciled.selectedTags; }); }); }, [topicId, topic?.label, topic?.query, topic?.brief]); useEffect(() => { load(); syncActiveJobs(); }, [load, syncActiveJobs]); useEffect(() => { if (!analyzing && !scanning) return; const timer = window.setInterval(syncActiveJobs, 2000); return () => window.clearInterval(timer); }, [analyzing, scanning, syncActiveJobs]); useEffect(() => { if (!analyzing) { setAnalyzeElapsed(0); return; } const timer = window.setInterval(() => { setAnalyzeElapsed((s) => s + 1); }, 1000); return () => window.clearInterval(timer); }, [analyzing]); useEffect(() => { function onJobCompleted(event: Event) { const { job } = (event as CustomEvent).detail as { job: { topicId?: string | null; type: string; status: string; result?: string | null; error?: string | null; progress?: string | null; progressDetail?: string | null; }; }; if (job.topicId !== topicId) return; if (job.type === "analyze-topic") { setAnalyzing(false); setAnalyzeProgress(null); if (job.status === "completed") { setAnalyzeError(null); load(); } else if (job.status === "failed") { setAnalyzeError(job.error ?? "AI 分析失敗"); } } if (job.type === "scan") { setScanning(false); setActiveScanJobId(null); if (job.status === "completed") { setScanProgress(job.progress ?? "海巡完成"); setScanProgressDetail(job.progressDetail ?? null); setShowScanSummary(true); } else { setScanProgress(null); setScanProgressDetail(null); setShowScanSummary(false); if (job.status === "failed") { setScanFeedback({ type: "error", title: "海巡失敗", message: job.error ?? "海巡執行失敗,請再試一次。", }); } } if (job.status === "completed" && job.result) { try { const scan = JSON.parse(job.result) as { id?: string; items?: Array<{ qualityTier?: string }>; }; if (scan.id) { setLastScanId(scan.id); router.push(`/scans/${topicId}/results`); } load(); } catch { load(); } } } } window.addEventListener("job-completed", onJobCompleted); return () => window.removeEventListener("job-completed", onJobCompleted); }, [topicId, load, router]); async function handleSaveBrief() { if (!topic) return; clearFeedback(); setSaving(true); const res = await fetch("/api/topics", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: topic.id, brief }), }); setSaving(false); if (!res.ok) { const data = await res.json().catch(() => ({})); showError((data as { error?: string }).error ?? "無法儲存 Brief", "儲存失敗"); return; } showSuccess("Brief 已儲存"); load(); } async function handleAnalyze() { if (!topic) return; if (!brief.trim()) { showWarning("請先填寫主題 Brief,AI 才能準確分析。", "尚無 Brief"); return; } if ( isPlacementGoal(topic.topicGoal) && !brandProfileId && !hasProductContext(productContext) ) { showWarning("置入模式需要品牌與至少一項產品。", "請先選擇品牌"); return; } setAnalyzeError(null); try { const res = await fetch("/api/analyze-topic", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ topicId: topic.id, brief }), }); let data: { error?: string; jobId?: string; message?: string } = {}; try { data = await res.json(); } catch { throw new Error("伺服器回應格式錯誤"); } if (!res.ok) { throw new Error(data.error ?? `AI 分析失敗(HTTP ${res.status})`); } setAnalyzing(true); setAnalyzeProgress(data.message ?? "已於背景執行…"); notify({ type: "info", title: `${topic.label} · 主題分析中`, message: "AI 正在建立研究地圖與搜尋標籤,可以先去其他頁面。完成後會再通知你。", href: `/scans/${topic.id}`, }); window.dispatchEvent(new Event("haixun:jobs-updated")); syncActiveJobs(); } catch (err) { const message = err instanceof Error ? err.message : "AI 分析失敗"; setAnalyzeError(message); } } function toggleTag(tag: string) { setSelectedTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag] ); } function selectAllAccountTags() { if (!researchMap) return; const accountTags = researchMap.suggestedTags .filter((t) => t.searchType === "帳號" || t.tag.startsWith("@")) .map((t) => t.tag); const fromSimilar = (researchMap.similarAccounts ?? []).map((a) => a.username.startsWith("@") ? a.username : `@${a.username}` ); setSelectedTags((prev) => [...new Set([...prev, ...accountTags, ...fromSimilar])]); } async function handleSaveTags() { if (!topic) return; setSaving(true); await fetch("/api/topics", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: topic.id, selectedTags }), }); setSaving(false); setTagsFeedback({ type: "success", message: "標籤已儲存" }); load(); } async function handleAnalyzeTags() { if (!topic || !researchMap || analyzingTags) return; setTagsFeedback(null); setAnalyzingTags(true); try { const res = await fetch("/api/analyze-tags", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ topicId: topic.id }), }); const data = await parseFetchJson<{ error?: string; researchMap?: ResearchMap; selectedTags?: string[]; count?: number; }>(res); if (!res.ok || !data.researchMap || !data.selectedTags) { throw new Error(data.error ?? "搜尋標籤分析沒有回傳可用結果"); } setResearchMap(data.researchMap); setSelectedTags(data.selectedTags); setTagsFeedback({ type: "success", message: `已重新產生 ${data.count ?? data.selectedTags.length} 個自然搜尋詞,研究地圖未重新分析。`, }); } catch (error) { setTagsFeedback({ type: "error", title: "標籤分析失敗", message: error instanceof Error ? error.message : "請重試或更換研究模型", }); } finally { setAnalyzingTags(false); } } async function handleScan() { if (!topic) return; setScanFeedback(null); setShowScanSummary(false); const placement = isPlacementGoal(topic.topicGoal); if (!placement && selectedTags.length === 0) { setScanFeedback({ type: "warning", title: "請先選擇標籤", message: "完成 AI 分析後,至少選擇一個搜尋標籤再海巡。", }); return; } if ( placement && (!researchMap || (researchMap.questions.length === 0 && researchMap.pillars.length === 0)) ) { setScanFeedback({ type: "warning", title: "請先完成 AI 分析", message: "置入模式需要研究地圖的「受眾會問什麼」與「內容支柱」才能自動海巡。", }); return; } setScanning(true); try { const res = await fetch("/api/scan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ topicId: topic.id, useTags: !placement, selectedTags, }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? "無法啟動海巡"); setActiveScanJobId(data.jobId ?? null); setScanProgress(data.message ?? "海巡已啟動…"); notify({ type: "info", title: `${topic.label} · 海巡中`, message: "正在搜尋 Threads 並篩選素材,完成後會再通知你。", href: `/scans/${topic.id}`, }); window.dispatchEvent(new Event("haixun:jobs-updated")); syncActiveJobs(); } catch (error) { setScanning(false); setScanFeedback({ type: "error", title: "海巡失敗", message: error instanceof Error ? error.message : "無法啟動海巡", }); } } async function handleCancelScan() { if (!activeScanJobId) return; setCancellingScan(true); try { const res = await fetch(`/api/jobs/${activeScanJobId}/cancel`, { method: "POST" }); const data = await res.json(); if (!res.ok) { setScanFeedback({ type: "error", title: "取消失敗", message: data.error ?? "無法停止海巡", }); return; } setScanning(false); setScanProgress(null); setScanProgressDetail(null); setActiveScanJobId(null); setScanFeedback({ type: "info", message: "海巡已停止" }); syncActiveJobs(); } finally { setCancellingScan(false); } } async function handleGenerateMatrix() { if (!lastScanId) { setScanFeedback({ type: "warning", message: "請先完成海巡再生成內容矩陣。" }); return; } setGenerating(true); const res = await fetch("/api/generate-matrix", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scanId: lastScanId }), }); const data = await res.json(); setGenerating(false); if (!res.ok) { notify({ type: "error", title: "生成內容矩陣失敗", message: data.error }); return; } notify({ type: "success", title: "內容矩陣已生成", message: `共 ${data.drafts?.length ?? 0} 篇`, href: "/matrix", }); router.push("/matrix"); } if (!topic) { return (
); } return (

帳號 8D 風格策略

{style8DReady ? "已套用" : "尚未設定"}

這份策略會自動影響後續產文與回覆;D1–D8 欄位在帳號策略頁編輯。

{feedback && ( )}
① 主題 Brief