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

706 lines
28 KiB
TypeScript
Raw Permalink 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 { 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>
);
}