2026-06-21 12:50:31 +00:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
Ban,
|
|
|
|
|
|
ExternalLink,
|
|
|
|
|
|
HelpCircle,
|
|
|
|
|
|
Layers,
|
|
|
|
|
|
Target,
|
|
|
|
|
|
UserCircle,
|
|
|
|
|
|
Users,
|
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
|
import {
|
2026-06-21 16:28:26 +00:00
|
|
|
|
SIMILAR_ACCOUNT_CONFIDENCE_LABELS,
|
2026-06-21 12:50:31 +00:00
|
|
|
|
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;
|
2026-06-21 16:28:26 +00:00
|
|
|
|
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;
|
2026-06-21 12:50:31 +00:00
|
|
|
|
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>
|
|
|
|
|
|
)}
|
2026-06-21 16:28:26 +00:00
|
|
|
|
{confidenceLabel && (
|
|
|
|
|
|
<Badge variant="outline" className={cn("px-1.5 py-0 text-[9px]", confidenceColor)}>
|
|
|
|
|
|
{confidenceLabel}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)}
|
2026-06-21 12:50:31 +00:00
|
|
|
|
{sourceLabel && (
|
|
|
|
|
|
<Badge variant="outline" className="px-1.5 py-0 text-[9px]">
|
|
|
|
|
|
{sourceLabel}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-06-21 16:28:26 +00:00
|
|
|
|
{daysSinceActive !== null && (
|
|
|
|
|
|
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
|
|
|
|
|
{daysSinceActive <= 1 ? "最近活躍" : `${daysSinceActive} 天前活躍`}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2026-06-21 12:50:31 +00:00
|
|
|
|
{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">
|
2026-06-21 16:28:26 +00:00
|
|
|
|
尚未找到相似帳號(沒有高品質也無中低品質候選),可重試分析或在微調面板手動加入 @帳號
|
2026-06-21 12:50:31 +00:00
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</MapBlock>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|