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("請先填寫主題 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)}
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|