706 lines
28 KiB
TypeScript
706 lines
28 KiB
TypeScript
"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 貼文,約需 1~3 分鐘,請稍候…",
|
||
});
|
||
|
||
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 進行中…完成後會自動展開分析結果(約 1~3 分鐘)
|
||
</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>
|
||
);
|
||
}
|