haixunMaster/app/(dashboard)/scans/[topicId]/page.tsx

870 lines
31 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
"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("請先填寫主題 BriefAI 才能準確分析。", "尚無 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">
D1D8
</p>
</div>
<Button asChild variant="outline" size="sm">
<Link href="/accounts#style-8d"> D1D8</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)}
2026-06-21 16:28:26 +00:00
researchMap={researchMap}
2026-06-21 12:50:31 +00:00
/>
</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>
);
}