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

870 lines
31 KiB
TypeScript
Raw 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 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)}
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>
);
}