870 lines
31 KiB
TypeScript
870 lines
31 KiB
TypeScript
"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<Topic | null>(null);
|
||
const [brief, setBrief] = useState("");
|
||
const [productContext, setProductContext] = useState("");
|
||
const [brandProfileId, setBrandProfileId] = useState<string | null>(null);
|
||
const [productProfileId, setProductProfileId] = useState<string | null>(null);
|
||
const [researchMap, setResearchMap] = useState<ResearchMap | null>(null);
|
||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||
const [analyzing, setAnalyzing] = useState(false);
|
||
const [analyzeElapsed, setAnalyzeElapsed] = useState(0);
|
||
const [analyzeError, setAnalyzeError] = useState<string | null>(null);
|
||
const [analyzeProgress, setAnalyzeProgress] = useState<string | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [analyzingTags, setAnalyzingTags] = useState(false);
|
||
const [scanning, setScanning] = useState(false);
|
||
const [scanProgress, setScanProgress] = useState<string | null>(null);
|
||
const [scanProgressDetail, setScanProgressDetail] = useState<string | null>(null);
|
||
const [showScanSummary, setShowScanSummary] = useState(false);
|
||
const [activeScanJobId, setActiveScanJobId] = useState<string | null>(null);
|
||
const [cancellingScan, setCancellingScan] = useState(false);
|
||
const [generating, setGenerating] = useState(false);
|
||
const [lastScanId, setLastScanId] = useState<string | null>(null);
|
||
const [style8DReady, setStyle8DReady] = useState(false);
|
||
const { feedback, clearFeedback, showError, showSuccess, showWarning } =
|
||
useActionFeedback();
|
||
const [brandFeedback, setBrandFeedback] = useState<ActionFeedback | null>(null);
|
||
const [tagsFeedback, setTagsFeedback] = useState<ActionFeedback | null>(null);
|
||
const [scanFeedback, setScanFeedback] = useState<ActionFeedback | null>(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 (
|
||
<div className="flex items-center justify-center py-20">
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className="mb-4">
|
||
<Button variant="ghost" size="sm" asChild>
|
||
<Link
|
||
href={`/scans?mode=${isPlacementGoal(topic.topicGoal) ? "placement" : "viral"}`}
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
返回主題列表
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
|
||
<PageHeader
|
||
title={topic.label}
|
||
description={`${topic.query} · ${TOPIC_GOAL_LABELS[isPlacementGoal(topic.topicGoal) ? "placement" : "viral"]}`}
|
||
/>
|
||
|
||
<div className="mb-5 flex flex-col gap-3 rounded-xl border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<p className="text-sm font-medium">帳號 8D 風格策略</p>
|
||
<Badge variant={style8DReady ? "success" : "secondary"}>
|
||
{style8DReady ? "已套用" : "尚未設定"}
|
||
</Badge>
|
||
</div>
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
這份策略會自動影響後續產文與回覆;D1–D8 欄位在帳號策略頁編輯。
|
||
</p>
|
||
</div>
|
||
<Button asChild variant="outline" size="sm">
|
||
<Link href="/accounts#style-8d">查看 D1–D8</Link>
|
||
</Button>
|
||
</div>
|
||
|
||
{feedback && (
|
||
<InlineAlert
|
||
type={feedback.type}
|
||
title={feedback.title}
|
||
message={feedback.message}
|
||
onDismiss={clearFeedback}
|
||
className="mb-4"
|
||
/>
|
||
)}
|
||
|
||
<div className="space-y-5">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>① 主題 Brief</CardTitle>
|
||
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<Textarea
|
||
value={brief}
|
||
onChange={(e) => setBrief(e.target.value)}
|
||
rows={5}
|
||
placeholder={
|
||
isPlacementGoal(topic.topicGoal)
|
||
? "例:服務自己在家幫狗洗澡的飼主,不要送寵物美容的受眾。"
|
||
: "例:30 歲上班族女性第一次備孕。想分享科學備孕知識,服務同齡焦慮型讀者。不要業配、偏方、純情緒宣洩。"
|
||
}
|
||
/>
|
||
{isPlacementGoal(topic.topicGoal) && (
|
||
<div className="space-y-2 rounded-lg border border-border bg-muted/40 p-4">
|
||
{brandFeedback && (
|
||
<InlineAlert
|
||
type={brandFeedback.type}
|
||
title={brandFeedback.title}
|
||
message={brandFeedback.message}
|
||
onDismiss={() => setBrandFeedback(null)}
|
||
/>
|
||
)}
|
||
<BrandProductPicker
|
||
brandId={brandProfileId}
|
||
productId={productProfileId}
|
||
onChange={async ({ brandId, productId, context }) => {
|
||
setBrandFeedback(null);
|
||
const res = await fetch("/api/topics", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: topic.id,
|
||
brandProfileId: brandId,
|
||
productProfileId: productId,
|
||
}),
|
||
});
|
||
let data: { error?: string; topic?: { productContext?: string } } = {};
|
||
try {
|
||
data = await parseFetchJson(res);
|
||
} catch (err) {
|
||
setBrandFeedback({
|
||
type: "error",
|
||
title: "儲存失敗",
|
||
message: err instanceof Error ? err.message : "伺服器回應異常",
|
||
});
|
||
return;
|
||
}
|
||
if (!res.ok) {
|
||
setBrandFeedback({
|
||
type: "error",
|
||
title: "儲存失敗",
|
||
message: data.error ?? "無法連結品牌與產品",
|
||
});
|
||
return;
|
||
}
|
||
setBrandProfileId(brandId);
|
||
setProductProfileId(productId);
|
||
if (data.topic?.productContext) {
|
||
setProductContext(data.topic.productContext);
|
||
} else if (context) {
|
||
setProductContext(context);
|
||
}
|
||
setBrandFeedback({ type: "success", message: "品牌與產品已連結" });
|
||
}}
|
||
/>
|
||
{!brandProfileId && !productProfileId && summarizeProductContext(productContext) && (
|
||
<p className="text-[12px] text-muted-foreground">
|
||
目前使用舊版內嵌資料:{summarizeProductContext(productContext)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
<Button size="sm" onClick={handleSaveBrief} disabled={saving}>
|
||
<Save className="h-3.5 w-3.5" />
|
||
{saving ? "儲存中…" : "儲存"}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<CardTitle>② 研究地圖</CardTitle>
|
||
|
||
</div>
|
||
<div className="flex shrink-0 flex-wrap gap-2">
|
||
{researchMap && (
|
||
<FeatureGate feature="research">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() =>
|
||
openRefine(topicId, { researchMap, topicLabel: topic.label })
|
||
}
|
||
>
|
||
<MessageSquare className="h-4 w-4" />
|
||
微調
|
||
</Button>
|
||
</FeatureGate>
|
||
)}
|
||
<FeatureGate feature="analyze">
|
||
<Button onClick={handleAnalyze} disabled={analyzing}>
|
||
{analyzing ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Sparkles className="h-4 w-4" />
|
||
)}
|
||
{analyzing ? `主題分析中… ${analyzeElapsed}s` : "主題分析"}
|
||
</Button>
|
||
</FeatureGate>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{analyzing && (
|
||
<p className="page-lead mb-3">
|
||
{analyzeProgress ?? "分析中…"}
|
||
{analyzeElapsed > 0 && ` · ${analyzeElapsed}s`}
|
||
</p>
|
||
)}
|
||
{analyzeError && !analyzing && (
|
||
<InlineAlert type="error" title="AI 分析失敗" message={analyzeError} className="mb-3" />
|
||
)}
|
||
<FeatureGate feature="analyze" showHint />
|
||
{researchMap ? (
|
||
<ResearchMapView
|
||
map={researchMap}
|
||
showSimilarAccounts={!isPlacementGoal(topic.topicGoal)}
|
||
/>
|
||
) : (
|
||
<p className="page-lead">填好 Brief 後按「分析」</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{researchMap && (
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<CardTitle>③ {isPlacementGoal(topic.topicGoal) ? "海巡關鍵字" : "搜尋標籤"}</CardTitle>
|
||
<CardDescription className="mt-1">
|
||
{isPlacementGoal(topic.topicGoal)
|
||
? "以真人會搜尋的痛點、求助與選購詞找出高意向受眾"
|
||
: "勾選後以 Threads API 與網搜找參考貼文;可一併勾選相似帳號"}
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex shrink-0 flex-wrap gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={handleAnalyzeTags}
|
||
disabled={analyzingTags}
|
||
>
|
||
{analyzingTags ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
)}
|
||
{analyzingTags ? "分析標籤中…" : "重新分析標籤"}
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={handleSaveTags} disabled={saving || analyzingTags}>
|
||
儲存標籤
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{tagsFeedback && (
|
||
<InlineAlert
|
||
type={tagsFeedback.type}
|
||
title={tagsFeedback.title}
|
||
message={tagsFeedback.message}
|
||
onDismiss={() => setTagsFeedback(null)}
|
||
/>
|
||
)}
|
||
<SuggestedTagsPicker
|
||
tags={researchMap.suggestedTags}
|
||
selected={selectedTags}
|
||
onToggle={toggleTag}
|
||
onSelectAllAccounts={selectAllAccountTags}
|
||
hideAccounts={isPlacementGoal(topic.topicGoal)}
|
||
researchMap={researchMap}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>④ 海巡與產出</CardTitle>
|
||
<CardDescription>
|
||
{isPlacementGoal(topic.topicGoal)
|
||
? "分析完成後一鍵海巡:自動用受眾問題與內容支柱搜尋,再以「不要碰」過濾"
|
||
: "海巡後生成內容矩陣"}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
{scanFeedback && (
|
||
<InlineAlert
|
||
type={scanFeedback.type}
|
||
title={scanFeedback.title}
|
||
message={scanFeedback.message}
|
||
onDismiss={() => setScanFeedback(null)}
|
||
/>
|
||
)}
|
||
{(scanning || showScanSummary) && (scanProgress || scanProgressDetail) && (
|
||
<div className="space-y-2 rounded-lg border border-border bg-muted/50 p-3">
|
||
<JobProgressPanel
|
||
summary={scanProgress ?? (scanning ? "背景海巡中…" : "海巡完成")}
|
||
progressDetailRaw={scanProgressDetail}
|
||
completed={!scanning && showScanSummary}
|
||
jobType="scan"
|
||
/>
|
||
{scanning ? (
|
||
<p className="page-lead">可換頁 · 取消於下個子任務後生效</p>
|
||
) : (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<p className="page-lead">海巡與留言抓取已完成,請自行挑選貼文</p>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-7 px-2 text-[12px]"
|
||
onClick={() => {
|
||
setShowScanSummary(false);
|
||
setScanProgress(null);
|
||
setScanProgressDetail(null);
|
||
}}
|
||
>
|
||
關閉
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
<FeatureGate feature="scan" showHint className="mb-3" />
|
||
<div className="flex flex-wrap gap-2">
|
||
<FeatureGate feature="scan">
|
||
<Button
|
||
onClick={handleScan}
|
||
disabled={
|
||
scanning ||
|
||
(!isPlacementGoal(topic.topicGoal) && selectedTags.length === 0) ||
|
||
(isPlacementGoal(topic.topicGoal) && !researchMap)
|
||
}
|
||
>
|
||
<Radar className="h-4 w-4" />
|
||
{scanning
|
||
? "海巡中…"
|
||
: isPlacementGoal(topic.topicGoal)
|
||
? "置入海巡"
|
||
: `綜合海巡(${selectedTags.length} 個標籤)`}
|
||
</Button>
|
||
</FeatureGate>
|
||
{scanning && activeScanJobId && (
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleCancelScan}
|
||
disabled={cancellingScan}
|
||
>
|
||
{cancellingScan ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : null}
|
||
停止海巡
|
||
</Button>
|
||
)}
|
||
{!isPlacementGoal(topic.topicGoal) && (
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleGenerateMatrix}
|
||
disabled={generating || !lastScanId}
|
||
>
|
||
<Table2 className="h-4 w-4" />
|
||
{generating ? "生成中…" : "生成內容矩陣"}
|
||
</Button>
|
||
)}
|
||
<Button variant="ghost" asChild>
|
||
<Link href={`/scans/${topicId}/results`}>
|
||
查看海巡成果
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
</div>
|
||
);
|
||
}
|