255 lines
8.7 KiB
TypeScript
255 lines
8.7 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Ban,
|
||
ExternalLink,
|
||
HelpCircle,
|
||
Layers,
|
||
Target,
|
||
UserCircle,
|
||
Users,
|
||
} from "lucide-react";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import {
|
||
SIMILAR_ACCOUNT_CONFIDENCE_LABELS,
|
||
SIMILAR_ACCOUNT_SOURCE_LABELS,
|
||
threadsProfileUrl,
|
||
type ResearchMap,
|
||
} from "@/lib/types/research";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
interface ResearchMapViewProps {
|
||
map: ResearchMap;
|
||
/** 置入模式不顯示相似帳號(僅爆款模仿需要) */
|
||
showSimilarAccounts?: boolean;
|
||
}
|
||
|
||
function HeroBlock({
|
||
icon,
|
||
label,
|
||
children,
|
||
}: {
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="rounded-xl border border-border bg-gradient-to-br from-muted/60 to-muted/20 p-4">
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80 text-muted-foreground ring-1 ring-border">
|
||
{icon}
|
||
</div>
|
||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||
{label}
|
||
</p>
|
||
</div>
|
||
<p className="text-[14px] leading-relaxed text-foreground">{children}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MapBlock({
|
||
icon,
|
||
title,
|
||
count,
|
||
children,
|
||
className,
|
||
tone = "default",
|
||
}: {
|
||
icon: React.ReactNode;
|
||
title: string;
|
||
count?: number;
|
||
children: React.ReactNode;
|
||
className?: string;
|
||
tone?: "default" | "exclude";
|
||
}) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"rounded-xl border border-border bg-card p-4",
|
||
tone === "exclude" && "border-destructive/25 bg-destructive/[0.03]",
|
||
className
|
||
)}
|
||
>
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<div
|
||
className={cn(
|
||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ring-1 ring-border",
|
||
tone === "exclude"
|
||
? "bg-destructive/10 text-destructive"
|
||
: "bg-muted text-muted-foreground"
|
||
)}
|
||
>
|
||
{icon}
|
||
</div>
|
||
<h3 className="text-[13px] font-semibold text-foreground">{title}</h3>
|
||
{count !== undefined && count > 0 && (
|
||
<Badge variant="secondary" className="ml-auto px-1.5 py-0 text-[10px]">
|
||
{count}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ResearchMapView({ map, showSimilarAccounts = true }: ResearchMapViewProps) {
|
||
const accounts = showSimilarAccounts ? (map.similarAccounts ?? []) : [];
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="grid gap-3 lg:grid-cols-2">
|
||
<HeroBlock icon={<Users className="h-3.5 w-3.5" />} label="受眾是誰">
|
||
{map.audienceSummary}
|
||
</HeroBlock>
|
||
<HeroBlock icon={<Target className="h-3.5 w-3.5" />} label="內容目標">
|
||
{map.contentGoal}
|
||
</HeroBlock>
|
||
</div>
|
||
|
||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||
{map.questions.length > 0 && (
|
||
<MapBlock
|
||
icon={<HelpCircle className="h-3.5 w-3.5" />}
|
||
title="受眾會問什麼"
|
||
count={map.questions.length}
|
||
className="md:col-span-1 xl:col-span-1"
|
||
>
|
||
<ol className="space-y-2.5">
|
||
{map.questions.map((q, i) => (
|
||
<li key={q} className="flex gap-2.5">
|
||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[10px] font-semibold text-primary">
|
||
{i + 1}
|
||
</span>
|
||
<span className="text-[13px] leading-relaxed text-foreground/90">{q}</span>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</MapBlock>
|
||
)}
|
||
|
||
{map.pillars.length > 0 && (
|
||
<MapBlock
|
||
icon={<Layers className="h-3.5 w-3.5" />}
|
||
title="內容支柱"
|
||
count={map.pillars.length}
|
||
>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{map.pillars.map((p) => (
|
||
<span
|
||
key={p}
|
||
className="inline-flex rounded-lg bg-secondary px-2.5 py-1 text-[12px] font-medium text-secondary-foreground"
|
||
>
|
||
{p}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</MapBlock>
|
||
)}
|
||
|
||
{map.exclusions.length > 0 && (
|
||
<MapBlock
|
||
icon={<Ban className="h-3.5 w-3.5" />}
|
||
title="不要碰"
|
||
count={map.exclusions.length}
|
||
tone="exclude"
|
||
>
|
||
<ul className="space-y-1.5">
|
||
{map.exclusions.map((ex) => (
|
||
<li key={ex} className="flex gap-2 text-[13px] leading-relaxed text-foreground/80">
|
||
<span className="shrink-0 text-destructive/70">×</span>
|
||
<span>{ex}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</MapBlock>
|
||
)}
|
||
</div>
|
||
|
||
{showSimilarAccounts && (
|
||
<MapBlock
|
||
icon={<UserCircle className="h-3.5 w-3.5" />}
|
||
title="相似帳號"
|
||
count={accounts.length}
|
||
>
|
||
{accounts.length > 0 ? (
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
{accounts.map((a) => {
|
||
const profileUrl = a.profileUrl ?? threadsProfileUrl(a.username);
|
||
const sourceLabel = a.source ? SIMILAR_ACCOUNT_SOURCE_LABELS[a.source] : null;
|
||
const confidenceLabel = a.confidence ? SIMILAR_ACCOUNT_CONFIDENCE_LABELS[a.confidence] : null;
|
||
const confidenceColor =
|
||
a.confidence === "high"
|
||
? "bg-emerald-500/10 text-emerald-600 border-emerald-200"
|
||
: a.confidence === "medium"
|
||
? "bg-amber-500/10 text-amber-600 border-amber-200"
|
||
: "bg-muted text-muted-foreground border-border";
|
||
const daysSinceActive = a.lastActiveAt
|
||
? Math.floor((Date.now() - new Date(a.lastActiveAt).getTime()) / 86400000)
|
||
: null;
|
||
return (
|
||
<div
|
||
key={a.username}
|
||
className="rounded-lg border border-border bg-muted/30 px-3 py-2.5"
|
||
>
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
{profileUrl ? (
|
||
<a
|
||
href={profileUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-[13px] font-semibold text-primary hover:underline"
|
||
>
|
||
@{a.username}
|
||
</a>
|
||
) : (
|
||
<span className="text-[13px] font-semibold text-foreground">
|
||
@{a.username}
|
||
</span>
|
||
)}
|
||
{confidenceLabel && (
|
||
<Badge variant="outline" className={cn("px-1.5 py-0 text-[9px]", confidenceColor)}>
|
||
{confidenceLabel}
|
||
</Badge>
|
||
)}
|
||
{sourceLabel && (
|
||
<Badge variant="outline" className="px-1.5 py-0 text-[9px]">
|
||
{sourceLabel}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
{daysSinceActive !== null && (
|
||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||
{daysSinceActive <= 1 ? "最近活躍" : `${daysSinceActive} 天前活躍`}
|
||
</p>
|
||
)}
|
||
{a.postUrl ? (
|
||
<a
|
||
href={a.postUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="mt-1 inline-flex items-start gap-1 text-[12px] leading-relaxed text-muted-foreground hover:text-foreground"
|
||
>
|
||
<span>{a.reason}</span>
|
||
<ExternalLink className="mt-0.5 h-3 w-3 shrink-0 opacity-50" />
|
||
</a>
|
||
) : (
|
||
<p className="mt-1 text-[12px] leading-relaxed text-muted-foreground">
|
||
{a.reason}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<p className="text-[13px] text-muted-foreground">
|
||
尚未找到相似帳號(沒有高品質也無中低品質候選),可重試分析或在微調面板手動加入 @帳號
|
||
</p>
|
||
)}
|
||
</MapBlock>
|
||
)}
|
||
</div>
|
||
);
|
||
} |