haixunMaster/components/inspiration/topic-scan-results.tsx

706 lines
28 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import {
ChevronDown,
ChevronUp,
ExternalLink,
Flame,
Loader2,
MessageSquarePlus,
ScanSearch,
Sparkles,
Table2,
} from "lucide-react";
import { ViralAnalysisPanel } from "@/components/viral-analysis-panel";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { FeatureGate } from "@/components/layout/feature-gate";
import { ScanPipelineSummary } from "@/components/inspiration/scan-pipeline-summary";
import { EmptyState } from "@/components/layout/empty-state";
import { notify } from "@/lib/notifications/store";
import { parseMediaUrls, parseViralAnalysis } from "@/lib/types/viral";
import { hasProductContext, summarizeProductContext } from "@/lib/types/product-context";
import { isPlacementGoal } from "@/lib/types/topic-goal";
import { cn } from "@/lib/utils";
interface Reply {
id: string;
text: string;
authorName?: string | null;
likeCount?: number | null;
}
interface OutreachDraftPreview {
id: string;
text: string;
angle?: string | null;
rationale?: string | null;
}
interface OutreachTargetPreview {
id: string;
reason?: string | null;
drafts: OutreachDraftPreview[];
}
export interface ScanItem {
id: string;
text: string;
authorName?: string | null;
permalink?: string | null;
likeCount?: number | null;
replyCount?: number | null;
score: number;
searchTag?: string | null;
relevanceScore?: number | null;
placementScore?: number | null;
placementReason?: string | null;
qualityTier?: string | null;
qualityReason?: string | null;
combinedScore?: number | null;
mediaUrls?: string | null;
mediaType?: string | null;
viralAnalysis?: string | null;
replies: Reply[];
outreachTargets?: OutreachTargetPreview[];
}
export interface Scan {
id: string;
createdAt: string;
scanMode?: string;
scanGoal?: string | null;
scanTags?: string | null;
searchSource?: string | null;
repliesFetched?: boolean;
repliesCount?: number;
topic: {
id: string;
label: string;
query: string;
topicGoal?: string | null;
productContext?: string | null;
};
items: ScanItem[];
}
function truncateText(text: string, max = 72) {
const trimmed = (text ?? "").replace(/\s+/g, " ").trim();
if (!trimmed) return "(無內文)";
if (trimmed.length <= max) return trimmed;
return `${trimmed.slice(0, max)}`;
}
function CollapsibleTrigger({
onToggle,
className,
children,
}: {
onToggle: () => void;
className?: string;
children: React.ReactNode;
}) {
return (
<div
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggle();
}
}}
className={cn("cursor-pointer text-left outline-none focus-visible:ring-2 focus-visible:ring-ring", className)}
>
{children}
</div>
);
}
function parseScanTags(raw: string | null | undefined): string[] {
if (!raw) return [];
try {
return JSON.parse(raw) as string[];
} catch {
return [];
}
}
interface TopicScanResultsProps {
scans: Scan[];
onReload: () => Promise<void>;
emptyAction?: React.ReactNode;
}
export function TopicScanResults({ scans, onReload, emptyAction }: TopicScanResultsProps) {
const router = useRouter();
const [scanExpanded, setScanExpanded] = useState<Record<string, boolean>>({});
const [itemExpanded, setItemExpanded] = useState<Record<string, boolean>>({});
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [generating, setGenerating] = useState<string | null>(null);
const [matrixGen, setMatrixGen] = useState<string | null>(null);
const [batchAnalyzing, setBatchAnalyzing] = useState<string | null>(null);
const [generatingReplyId, setGeneratingReplyId] = useState<string | null>(null);
const [viralExpanded, setViralExpanded] = useState<Record<string, boolean>>({});
async function handleGenerate(scanId: string) {
setGenerating(scanId);
const res = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scanId }),
});
const data = await res.json();
setGenerating(null);
if (!res.ok) {
notify({ type: "error", title: "生成草稿失敗", message: data.error });
return;
}
notify({
type: "success",
title: "草稿已生成",
message: `${data.drafts?.length ?? 0}`,
href: "/",
});
}
async function handleGenerateMatrix(scanId: string) {
setMatrixGen(scanId);
const res = await fetch("/api/generate-matrix", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scanId }),
});
const data = await res.json();
setMatrixGen(null);
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");
}
async function handleGenerateReply(scanItemId: string, productContext: string) {
if (!hasProductContext(productContext)) {
notify({
type: "warning",
title: "請先填寫品牌與產品",
message: "請到主題設定選擇品牌與產品後再生成回覆。",
});
return;
}
setGeneratingReplyId(scanItemId);
const res = await fetch("/api/outreach/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scanItemId, productContext }),
});
const data = await res.json();
setGeneratingReplyId(null);
if (!res.ok) {
notify({ type: "error", title: "生成回覆失敗", message: data.error });
return;
}
setItemExpanded((prev) => ({ ...prev, [scanItemId]: true }));
await onReload();
notify({
type: "success",
title: "回覆草稿已生成",
message: "請審核後再到主動互動頁發布",
href: "/outreach",
});
}
function toggleScan(id: string) {
setScanExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
}
function toggleItemDetail(id: string) {
setItemExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
}
function toggleReplies(id: string) {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
}
function toggleViral(id: string) {
setViralExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
}
function expandAllScans() {
setScanExpanded(Object.fromEntries(scans.map((s) => [s.id, true])));
}
function collapseAllScans() {
setScanExpanded(Object.fromEntries(scans.map((s) => [s.id, false])));
}
function revealAnalyzedItems(itemIds: string[], scanId: string) {
setScanExpanded((prev) => ({ ...prev, [scanId]: true }));
setItemExpanded((prev) => {
const next = { ...prev };
for (const id of itemIds) next[id] = true;
return next;
});
setViralExpanded((prev) => {
const next = { ...prev };
for (const id of itemIds) next[id] = true;
return next;
});
}
async function handleBatchAnalyze(scanId: string) {
setBatchAnalyzing(scanId);
setScanExpanded((prev) => ({ ...prev, [scanId]: true }));
notify({
type: "info",
title: "爆款分析進行中",
message: "正在分析 Top5 貼文,約需 13 分鐘,請稍候…",
});
try {
const res = await fetch("/api/analyze-viral", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scanId, limit: 5 }),
});
const data = await res.json();
if (!res.ok) {
notify({ type: "error", title: "批次爆款分析失敗", message: data.error });
return;
}
const results = (data.results ?? []) as Array<{ item?: { id?: string } }>;
const itemIds = results
.map((row) => row.item?.id)
.filter((id): id is string => typeof id === "string");
if (itemIds.length === 0) {
notify({
type: "warning",
title: "沒有可分析的貼文",
message: "請確認有通過品質篩選(優質/可參考)的素材。",
});
return;
}
revealAnalyzedItems(itemIds, scanId);
await onReload();
revealAnalyzedItems(itemIds, scanId);
notify({
type: "success",
title: "爆款分析完成",
message: `${itemIds.length} 篇,已自動展開結果`,
});
} catch {
notify({
type: "error",
title: "批次爆款分析失敗",
message: "連線中斷或逾時,請稍後再試。",
});
} finally {
setBatchAnalyzing(null);
}
}
if (scans.length === 0) {
return (
<EmptyState
icon={ScanSearch}
title="尚無海巡成果"
description="到主題設定完成分析與海巡後,成果會顯示在這裡"
action={emptyAction}
/>
);
}
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="ghost" onClick={expandAllScans}>
</Button>
<Button size="sm" variant="ghost" onClick={collapseAllScans}>
</Button>
</div>
<div className="space-y-5">
{scans.map((scan, si) => {
const placementScan = isPlacementGoal(scan.scanGoal ?? scan.topic.topicGoal);
const tags = parseScanTags(scan.scanTags);
const visibleItems = scan.items;
const isScanOpen = scanExpanded[scan.id] ?? false;
const analyzedCount = scan.items.filter((i) => i.viralAnalysis).length;
return (
<Card key={scan.id} className="animate-fade-in-up" style={{ animationDelay: `${si * 60}ms` }}>
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<CollapsibleTrigger
onToggle={() => toggleScan(scan.id)}
className="group min-w-0 flex-1"
>
<div className="flex items-start gap-2">
<ChevronDown
className={cn(
"mt-1 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isScanOpen && "rotate-180"
)}
/>
<div className="min-w-0">
<CardTitle className="group-hover:text-foreground/90">
{new Date(scan.createdAt).toLocaleString("zh-TW")}
</CardTitle>
<CardDescription>
{scan.scanMode === "multi-tag" && tags.length > 0 && `${tags.length} 個標籤`}
{analyzedCount > 0 && `${tags.length > 0 ? " · " : ""}${analyzedCount} 篇已爆款分析`}
{!isScanOpen && " · 點擊展開貼文"}
</CardDescription>
<ScanPipelineSummary
className="mt-2"
itemCount={scan.items.length}
repliesFetched={scan.repliesFetched}
repliesCount={scan.repliesCount}
/>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Badge
variant={
scan.searchSource === "browser" || scan.searchSource === "web"
? "secondary"
: "default"
}
className="text-[10px]"
>
{scan.searchSource === "browser"
? "瀏覽器爬蟲"
: scan.searchSource === "web"
? "網路搜尋"
: scan.searchSource === "hybrid"
? "API + 網搜"
: "官方 API"}
</Badge>
</div>
{!isScanOpen && tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{tags.slice(0, 6).map((tag) => (
<Badge key={tag} variant="outline" className="text-[11px]">
{tag}
</Badge>
))}
{tags.length > 6 && (
<Badge variant="outline" className="text-[11px]">
+{tags.length - 6}
</Badge>
)}
</div>
)}
</div>
</div>
</CollapsibleTrigger>
<div className="flex flex-wrap gap-2 sm:shrink-0">
{!placementScan && (
<>
<FeatureGate feature="viralAnalysis">
<Button
size="sm"
variant="outline"
onClick={() => handleBatchAnalyze(scan.id)}
disabled={batchAnalyzing === scan.id}
>
{batchAnalyzing === scan.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Flame className="h-3.5 w-3.5" />
)}
{batchAnalyzing === scan.id ? "分析中…" : "爆款分析 Top5"}
</Button>
</FeatureGate>
<FeatureGate feature="generate">
<Button
size="sm"
onClick={() => handleGenerateMatrix(scan.id)}
disabled={matrixGen === scan.id}
>
<Table2 className="h-3.5 w-3.5" />
{matrixGen === scan.id ? "生成中…" : "內容矩陣"}
</Button>
</FeatureGate>
<FeatureGate feature="generate">
<Button
size="sm"
variant="outline"
onClick={() => handleGenerate(scan.id)}
disabled={generating === scan.id}
>
<Sparkles className="h-3.5 w-3.5" />
{generating === scan.id ? "生成中…" : "快速草稿"}
</Button>
</FeatureGate>
</>
)}
{placementScan && (
<Button size="sm" variant="outline" asChild>
<Link href="/outreach"></Link>
</Button>
)}
</div>
</div>
{isScanOpen && tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5 pl-6">
{tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-[11px]">
{tag}
</Badge>
))}
</div>
)}
{isScanOpen && placementScan && (
<div className="mt-4 rounded-lg border border-border bg-muted/50 p-4 pl-6">
<p className="text-sm font-semibold"></p>
<p className="mt-1 text-[12px] text-muted-foreground">
{summarizeProductContext(scan.topic.productContext) ??
"尚未設定,請到主題設定或品牌與產品頁選用"}
</p>
<Button size="sm" variant="outline" className="mt-3" asChild>
<Link href={`/scans/${scan.topic.id}`}></Link>
</Button>
</div>
)}
</CardHeader>
{batchAnalyzing === scan.id && (
<div className="mx-5 mb-2 flex items-center gap-2 rounded-lg border border-border bg-muted/60 px-3 py-2.5 text-[13px] text-muted-foreground">
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
Top5 13
</div>
)}
{isScanOpen && (
<CardContent className="space-y-2 pt-0">
{visibleItems.map((item) => {
const isItemOpen = itemExpanded[item.id] ?? false;
const outreachTarget = item.outreachTargets?.[0];
const hasDrafts = (outreachTarget?.drafts.length ?? 0) > 0;
return (
<div
key={item.id}
className={cn(
"rounded-lg border border-border bg-muted transition-colors hover:bg-secondary",
isItemOpen ? "p-4" : "px-3 py-2.5"
)}
>
<div className="flex items-start justify-between gap-3">
<CollapsibleTrigger
onToggle={() => toggleItemDetail(item.id)}
className="min-w-0 flex-1"
>
<div className="flex flex-wrap items-center gap-2">
{item.searchTag && (
<Badge variant="default" className="text-[11px]">
{item.searchTag}
</Badge>
)}
<span className="text-xs font-medium text-muted-foreground">
@{item.authorName ?? "匿名"}
</span>
<Badge variant="outline">{item.likeCount ?? 0} </Badge>
{(item.replyCount ?? 0) > 0 && (
<Badge variant="outline">{item.replyCount} </Badge>
)}
{hasDrafts && (
<Badge variant="success" className="text-[10px]">
稿
</Badge>
)}
{item.viralAnalysis && !isItemOpen && (
<Badge variant="success" className="text-[10px]">
</Badge>
)}
</div>
{item.permalink ? (
<a
href={item.permalink}
target="_blank"
rel="noopener noreferrer"
className={cn(
"mt-1.5 block text-[14px] leading-[1.65] underline-offset-2 hover:underline",
!isItemOpen && "text-muted-foreground"
)}
onClick={(e) => e.stopPropagation()}
>
{isItemOpen ? item.text : truncateText(item.text)}
</a>
) : (
<p
className={cn(
"mt-1.5 text-[14px] leading-[1.65]",
!isItemOpen && "text-muted-foreground"
)}
>
{isItemOpen ? item.text : truncateText(item.text)}
</p>
)}
</CollapsibleTrigger>
<div className="flex shrink-0 flex-col items-end gap-1">
{item.permalink && (
<a
href={item.permalink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="h-3 w-3" />
</a>
)}
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-[12px]"
onClick={() => toggleItemDetail(item.id)}
>
{isItemOpen ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
{isItemOpen ? "收合" : "展開"}
</Button>
</div>
</div>
{isItemOpen && (
<>
<div className="mt-2 flex flex-wrap items-center gap-2">
{placementScan ? (
<FeatureGate feature="outreach">
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-[12px]"
disabled={generatingReplyId === item.id}
onClick={() =>
handleGenerateReply(item.id, scan.topic.productContext ?? "")
}
>
{generatingReplyId === item.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<MessageSquarePlus className="h-3 w-3" />
)}
{hasDrafts ? "重新生成回覆" : "生成回覆"}
</Button>
</FeatureGate>
) : (
<FeatureGate feature="viralAnalysis">
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-[12px]"
onClick={() => toggleViral(item.id)}
>
<Flame className="h-3 w-3" />
{viralExpanded[item.id] ? "收合爆款分析" : "爆款分析"}
{item.viralAnalysis && (
<Badge variant="success" className="ml-1 text-[10px]">
</Badge>
)}
</Button>
</FeatureGate>
)}
{item.replies.length > 0 && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-[12px]"
onClick={() => toggleReplies(item.id)}
>
{expanded[item.id] ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
{item.replies.length}
</Button>
)}
</div>
{placementScan && hasDrafts && outreachTarget && (
<div className="mt-3 space-y-2 border-t border-border pt-3">
{outreachTarget.drafts.map((draft) => (
<div
key={draft.id}
className="rounded-lg bg-background px-3 py-2.5 ring-1 ring-border"
>
{draft.angle && (
<p className="text-[10px] font-semibold text-muted-foreground">
{draft.angle}
</p>
)}
<p className="mt-1 text-sm leading-relaxed">{draft.text}</p>
{draft.rationale && (
<p className="mt-1 text-[11px] text-muted-foreground">
{draft.rationale}
</p>
)}
</div>
))}
<Button size="sm" variant="link" className="h-7 px-0" asChild>
<Link href="/outreach"></Link>
</Button>
</div>
)}
{!placementScan && viralExpanded[item.id] && (
<ViralAnalysisPanel
scanItemId={item.id}
mediaUrls={parseMediaUrls(item.mediaUrls)}
viralAnalysis={parseViralAnalysis(item.viralAnalysis)}
onUpdate={onReload}
/>
)}
{expanded[item.id] && item.replies.length > 0 && (
<div className="mt-3 space-y-2 border-t border-border pt-3">
{item.replies.map((reply) => (
<div
key={reply.id}
className="rounded-lg bg-background px-3 py-2.5 ring-1 ring-border"
>
<p className="font-mono text-[10px] text-muted-foreground">
@{reply.authorName ?? "匿名"} · {reply.likeCount ?? 0}
</p>
<p className="mt-1 text-sm leading-relaxed">{reply.text}</p>
</div>
))}
</div>
)}
</>
)}
</div>
);
})}
</CardContent>
)}
</Card>
);
})}
</div>
</div>
);
}