Publish updated WeChat Radar

This commit is contained in:
joeseesun
2026-05-26 09:10:04 +08:00
parent 8abb29e13e
commit de7ec99fed
44 changed files with 2900 additions and 746 deletions
+58 -31
View File
@@ -1,5 +1,5 @@
import Link from 'next/link';
import { Flame } from 'lucide-react';
import { FileText, Flame } from 'lucide-react';
export interface ActiveGroup {
chatroom_id: string;
@@ -9,7 +9,7 @@ export interface ActiveGroup {
top_senders: Array<{ sender: string; count: number }>;
}
export default function ActiveGroupsList({ groups }: { groups: ActiveGroup[] }) {
export default function ActiveGroupsList({ groups, date }: { groups: ActiveGroup[]; date?: string }) {
const max = groups[0]?.total ?? 1;
return (
<div className="card p-5">
@@ -19,7 +19,7 @@ export default function ActiveGroupsList({ groups }: { groups: ActiveGroup[] })
</div>
<div className="text-[11px] text-[var(--text-3)]">
{groups.length} ·
{groups.length} · 10
</div>
</div>
@@ -30,7 +30,7 @@ export default function ActiveGroupsList({ groups }: { groups: ActiveGroup[] })
) : (
<div className="space-y-1">
{groups.slice(0, 12).map((g, i) => (
<Row key={g.chatroom_id} group={g} rank={i + 1} max={max} />
<Row key={g.chatroom_id} group={g} rank={i + 1} max={max} date={date} />
))}
</div>
)}
@@ -38,42 +38,69 @@ export default function ActiveGroupsList({ groups }: { groups: ActiveGroup[] })
);
}
function Row({ group, rank, max }: { group: ActiveGroup; rank: number; max: number }) {
function Row({
group,
rank,
max,
date,
}: {
group: ActiveGroup;
rank: number;
max: number;
date?: string;
}) {
const initial = group.name.slice(0, 2);
const senders = group.top_senders
.slice(0, 3)
.map((s) => s.sender)
.join(' · ');
const pct = (group.total / max) * 100;
const groupHref = `/groups/${encodeURIComponent(group.chatroom_id)}${date ? `?date=${date}` : ''}`;
const reportHref = `/reports/groups/${encodeURIComponent(group.chatroom_id)}?date=${date}`;
return (
<Link
href={`/groups/${encodeURIComponent(group.chatroom_id)}`}
className="grid grid-cols-[24px_36px_1fr_70px] items-center gap-3 rounded-md px-2 py-2 transition-colors hover:bg-[var(--surface-2)]"
>
<span className="rounded bg-[var(--surface-2)] py-0.5 text-center text-[10px] tabular-nums text-[var(--text-3)]">
{rank}
</span>
<div className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--border-soft)] bg-[var(--surface-2)] text-[11px] text-[var(--text-2)]">
{initial}
</div>
<div className="min-w-0">
<div className="truncate text-[13px] text-[var(--text)]">{group.name}</div>
{senders && (
<div className="truncate text-[11px] text-[var(--text-3)]">{senders}</div>
)}
</div>
<div className="text-right">
<div className="text-[14px] font-semibold tabular-nums text-[var(--text)]">
{group.total.toLocaleString()}
<div className="grid grid-cols-[1fr_auto] items-center gap-2 rounded-md px-2 py-2 transition-colors hover:bg-[var(--surface-2)]">
<Link
href={groupHref}
className="grid min-w-0 grid-cols-[24px_36px_1fr_88px] items-center gap-3"
title="查看群详情"
>
<span className="rounded bg-[var(--surface-2)] py-0.5 text-center text-[10px] tabular-nums text-[var(--text-3)]">
{rank}
</span>
<div className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--border-soft)] bg-[var(--surface-2)] text-[11px] text-[var(--text-2)]">
{initial}
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-[var(--surface-2)]">
<div
className="h-full rounded-full bg-[var(--accent)]"
style={{ width: `${pct}%` }}
/>
<div className="min-w-0">
<div className="truncate text-[13px] text-[var(--text)]">{group.name}</div>
{senders && (
<div className="truncate text-[11px] text-[var(--text-3)]">{senders}</div>
)}
</div>
</div>
</Link>
<div className="text-right">
<div className="text-[14px] font-semibold tabular-nums text-[var(--text)]">
{group.total.toLocaleString()}
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-[var(--surface-2)]">
<div
className="h-full rounded-full bg-[var(--accent)]"
style={{ width: `${pct}%` }}
/>
</div>
</div>
</Link>
{rank <= 10 && date ? (
<Link
href={reportHref}
className="inline-flex h-7 items-center gap-1 rounded border border-[var(--border-soft)] bg-[var(--accent-soft)] px-2 text-[11px] font-medium text-[var(--accent)] transition-colors hover:border-[var(--accent)]"
title="查看群日报"
>
<FileText size={12} />
</Link>
) : (
<span className="w-[52px]" />
)}
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
'use client';
import Link from 'next/link';
import { ExternalLink, Loader2, Search } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
type Result = {
id: string;
type: 'group' | 'topic' | 'person' | 'message' | 'link';
title: string;
subtitle: string;
href: string;
external?: boolean;
};
type SearchResponse = {
ok: boolean;
results: Result[];
};
const TYPE_LABEL: Record<Result['type'], string> = {
group: '群',
topic: '话题',
person: '人',
message: '消息',
link: '链接',
};
export default function GlobalSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (!boxRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, []);
useEffect(() => {
const q = query.trim();
if (q.length < 2) {
return;
}
const ctl = new AbortController();
const timer = window.setTimeout(async () => {
setLoading(true);
try {
const r = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
cache: 'no-store',
signal: ctl.signal,
});
const j = (await r.json()) as SearchResponse;
if (j.ok) {
setResults(j.results);
setOpen(true);
}
} catch (e) {
if (!(e instanceof DOMException && e.name === 'AbortError')) console.error(e);
} finally {
setLoading(false);
}
}, 220);
return () => {
window.clearTimeout(timer);
ctl.abort();
};
}, [query]);
return (
<div ref={boxRef} className="relative w-[280px]">
<div className="control-surface flex items-center gap-2 rounded-md px-2.5 py-1.5">
<Search size={13} className="text-[var(--text-3)]" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => query.trim().length >= 2 && setOpen(true)}
className="min-w-0 flex-1 bg-transparent text-[12px] outline-none placeholder:text-[var(--text-3)]"
placeholder="搜索群、话题、人、关键词"
/>
{loading && <Loader2 size={12} className="animate-spin text-[var(--text-3)]" />}
</div>
{open && query.trim().length >= 2 && (
<div className="absolute right-0 top-[calc(100%+8px)] z-40 max-h-[520px] w-[420px] overflow-y-auto rounded-lg border border-[var(--border-soft)] bg-[var(--surface)] p-2 shadow-[var(--shadow)]">
{results.length === 0 && !loading ? (
<div className="px-3 py-8 text-center text-[12px] text-[var(--text-3)]">
</div>
) : (
<div className="space-y-1">
{results.map((item) => (
<SearchItem key={item.id} item={item} onClick={() => setOpen(false)} />
))}
</div>
)}
</div>
)}
</div>
);
}
function SearchItem({ item, onClick }: { item: Result; onClick: () => void }) {
const inner = (
<>
<span className="mt-0.5 rounded border border-[var(--border-soft)] px-1.5 py-0.5 text-[10px] text-[var(--text-3)]">
{TYPE_LABEL[item.type]}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-[var(--text)]">
{item.title}
</span>
<span className="mt-0.5 block truncate text-[11px] text-[var(--text-3)]">
{item.subtitle}
</span>
</span>
{item.external && <ExternalLink size={12} className="text-[var(--text-3)]" />}
</>
);
const className =
'flex items-start gap-2 rounded-md px-2.5 py-2 transition-colors hover:bg-[var(--surface-2)]';
if (item.external) {
return (
<a href={item.href} target="_blank" rel="noreferrer" className={className} onClick={onClick}>
{inner}
</a>
);
}
return (
<Link href={item.href} className={className} onClick={onClick}>
{inner}
</Link>
);
}
+340 -362
View File
@@ -4,15 +4,15 @@ import { useMemo, useState } from 'react';
import Link from 'next/link';
import {
AlertTriangle,
ArrowRight,
Check,
Clipboard,
ExternalLink,
Flame,
Lightbulb,
FileText,
Link2,
Radar,
Target,
UserRoundCheck,
Sparkles,
Wrench,
} from 'lucide-react';
import type { ReactNode } from 'react';
@@ -61,6 +61,7 @@ export interface DashboardLinkHighlight {
title: string;
url: string;
domain: string;
source: string;
score: number;
verdict: string;
count: number;
@@ -107,23 +108,55 @@ export interface DashboardIntelligence {
anomalies: DashboardAnomalySignal[];
}
const EMPTY: DashboardIntelligence = {
date: '',
must_read: [],
opportunities: [],
signal_sources: [],
action_items: [],
topic_lifecycle: [],
link_highlights: [],
people_radar: [],
content_ideas: [],
anomalies: [],
};
type QueueItem = {
title: string;
href?: string;
external?: boolean;
};
export default function IntelligenceBrief({ intelligence }: { intelligence?: DashboardIntelligence }) {
const data = intelligence ?? EMPTY;
const articles = data.link_highlights.filter((item) => item.kind === 'article');
const tools = data.link_highlights.filter((item) => item.kind === 'tool');
const summary = useMemo(() => buildSummary(data, articles, tools), [data, articles, tools]);
const queue = useMemo(
() => ({
messages: data.must_read.slice(0, 2).map((item) => ({
title: item.title,
href: `/groups/${encodeURIComponent(item.chatroom_id)}?date=${data.date}`,
})),
articles: articles.slice(0, 2).map((item) => ({
title: item.title,
href: item.url,
external: true,
})),
tools: tools.slice(0, 2).map((item) => ({
title: item.title,
href: item.url,
external: true,
})),
anomalies: data.anomalies.slice(0, 2).map((item) => ({
title: item.title,
href: item.href,
external: item.href?.startsWith('http') ?? false,
})),
}),
[articles, data.anomalies, data.date, data.must_read, tools],
);
const [copied, setCopied] = useState(false);
const data =
intelligence ??
({
date: '',
must_read: [],
opportunities: [],
signal_sources: [],
action_items: [],
topic_lifecycle: [],
link_highlights: [],
people_radar: [],
content_ideas: [],
anomalies: [],
} satisfies DashboardIntelligence);
const summary = useMemo(() => buildDailySummary(data), [data]);
async function copySummary() {
await navigator.clipboard.writeText(summary);
@@ -133,88 +166,161 @@ export default function IntelligenceBrief({ intelligence }: { intelligence?: Das
return (
<div className="space-y-4">
<section className="card flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<div className="report-kicker">Briefing Note</div>
<div className="mt-1 flex items-center gap-1.5 text-[14px] font-semibold">
<Radar size={14} className="text-[var(--accent)]" />
</div>
<div className="mt-1 truncate text-[12px] text-[var(--text-2)]">
{summary.split('\n').slice(1, 4).join(' · ')}
<section className="card p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="report-kicker">Today Queue</div>
<div className="mt-1 flex items-center gap-1.5 text-[14px] font-semibold">
<Sparkles size={14} className="text-[var(--accent)]" />
</div>
<div className="mt-3 grid grid-cols-1 gap-2 lg:grid-cols-4">
<QueueBlock
label="消息"
items={queue.messages}
empty="暂无高信号消息"
/>
<QueueBlock
label="文章"
items={queue.articles}
empty="暂无文章"
/>
<QueueBlock
label="工具"
items={queue.tools}
empty="暂无工具"
/>
<QueueBlock
label="异动"
items={queue.anomalies}
empty="暂无异动"
/>
</div>
</div>
<button type="button" onClick={copySummary} className="btn shrink-0" disabled={!data.date}>
{copied ? <Check size={13} /> : <Clipboard size={13} />}
<span>{copied ? '已复制' : '复制摘要'}</span>
</button>
</div>
<button
type="button"
onClick={copySummary}
className="btn shrink-0"
disabled={!data.date}
title="复制今日情报摘要"
>
{copied ? <Check size={13} /> : <Clipboard size={13} />}
<span>{copied ? '已复制' : '复制摘要'}</span>
</button>
</section>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.1fr_1fr_1fr]">
<ActionPanel date={data.date} items={data.action_items} fallback={data.opportunities} />
<TopicLifecyclePanel topics={data.topic_lifecycle} />
<AnomalyPanel anomalies={data.anomalies} />
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.15fr_1fr_0.85fr]">
<MustReadPanel date={data.date} items={data.must_read} actions={data.action_items} />
<ResourcePanel articles={articles} tools={tools} />
<WatchPanel date={data.date} anomalies={data.anomalies} people={data.people_radar} />
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.1fr_1fr_1fr]">
<LinkHighlightPanel items={data.link_highlights} />
<PeopleRadarPanel people={data.people_radar} fallback={data.signal_sources} />
<ContentIdeaPanel ideas={data.content_ideas} />
</div>
<MustReadPanel date={data.date} items={data.must_read} />
</div>
);
}
function ActionPanel({
function QueueBlock({
label,
items,
empty,
}: {
label: string;
items: QueueItem[];
empty: string;
}) {
return (
<div className="rounded-md border border-[var(--border-soft)] bg-[var(--surface-2)] px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--accent)]">{label}</div>
<div className="mt-1 space-y-1">
{items.length === 0 ? (
<div className="text-[12px] text-[var(--text-3)]">{empty}</div>
) : (
items.map((item, i) => (
<QueueLink key={`${label}:${i}:${item.title}`} item={item} />
))
)}
</div>
</div>
);
}
function QueueLink({ item }: { item: QueueItem }) {
const content = (
<>
<span className="line-clamp-2 min-w-0 flex-1">{item.title}</span>
{item.href ? (
item.external ? (
<ExternalLink size={11} className="mt-1 shrink-0 text-[var(--text-3)]" />
) : (
<ArrowRight size={11} className="mt-1 shrink-0 text-[var(--text-3)]" />
)
) : null}
</>
);
const className =
'flex items-start gap-2 rounded px-1.5 py-1 text-[12px] leading-5 text-[var(--text-2)] transition-colors hover:bg-[var(--surface)] hover:text-[var(--text)]';
if (!item.href) return <div className={className}>{content}</div>;
if (item.external) {
return (
<a href={item.href} target="_blank" rel="noreferrer" className={className}>
{content}
</a>
);
}
return (
<Link href={item.href} className={className}>
{content}
</Link>
);
}
function MustReadPanel({
date,
items,
fallback,
actions,
}: {
date: string;
items: DashboardActionItem[];
fallback: DashboardOpportunityItem[];
items: DashboardSignalItem[];
actions: DashboardActionItem[];
}) {
const displayItems =
items.length > 0
? items
: fallback.map((item) => ({
...item,
why: '包含明确需求或行动线索,适合进入上下文判断',
urgency: 'medium' as const,
}));
const promoted = mergeSignals(actions, items).slice(0, 7);
return (
<section className="card min-h-[300px] p-4">
<section className="card min-h-[360px] p-4">
<PanelTitle
icon={<Target size={14} className="text-[var(--warn)]" />}
title="今日值得出手"
meta={`${displayItems.length}`}
icon={<Radar size={14} className="text-[var(--accent)]" />}
title="关键话题与消息"
meta={`${promoted.length}`}
/>
{displayItems.length === 0 ? (
<EmptyState text="暂无明确行动项" />
{promoted.length === 0 ? (
<EmptyState text="暂无高信号消息" />
) : (
<div className="mt-3 space-y-2">
{displayItems.slice(0, 6).map((item) => (
<div className="mt-3 space-y-1.5">
{promoted.map((item, index) => (
<Link
key={`${item.chatroom_id}:${item.local_id}`}
href={`/groups/${encodeURIComponent(item.chatroom_id)}?date=${date}`}
className="block rounded-md border border-transparent px-2 py-2 transition-colors hover:border-[rgba(213,162,83,0.34)] hover:bg-[var(--surface-2)]"
className="grid grid-cols-[24px_1fr_16px] items-start gap-2 rounded-md px-2 py-2 transition-colors hover:bg-[var(--surface-2)]"
>
<div className="flex items-center justify-between gap-2">
<span className="rounded border border-[rgba(213,162,83,0.22)] bg-[var(--warn-soft)] px-1.5 py-0.5 text-[10px] text-[var(--warn)]">
{item.action}
<span className="rounded bg-[var(--surface-2)] py-0.5 text-center text-[10px] tabular-nums text-[var(--text-3)]">
{index + 1}
</span>
<span className="min-w-0">
<span className="flex items-start justify-between gap-2">
<span className="line-clamp-2 text-[12px] font-medium leading-snug text-[var(--text)]">
{item.title}
</span>
{'action' in item && (
<span className={urgencyClass((item as DashboardActionItem).urgency)}>
{(item as DashboardActionItem).action}
</span>
)}
</span>
<span className={urgencyClass(item.urgency)}>{urgencyText(item.urgency)}</span>
</div>
<div className="mt-1 line-clamp-2 text-[12px] leading-snug text-[var(--text)]">{item.title}</div>
<div className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">{item.why}</div>
<span className="mt-1 flex min-w-0 items-center gap-1.5 text-[10px] text-[var(--text-3)]">
<span className="truncate">
{item.chat_name} · {item.sender}
</span>
<span className="shrink-0 tabular-nums">{item.time.slice(11)}</span>
</span>
<span className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">
{'why' in item ? (item as DashboardActionItem).why : item.reasons.join(' / ')}
</span>
</span>
<ArrowRight size={12} className="mt-0.5 text-[var(--text-3)]" />
</Link>
))}
</div>
@@ -223,53 +329,112 @@ function ActionPanel({
);
}
function TopicLifecyclePanel({ topics }: { topics: DashboardTopicLifecycle[] }) {
function ResourcePanel({
articles,
tools,
}: {
articles: DashboardLinkHighlight[];
tools: DashboardLinkHighlight[];
}) {
return (
<section className="card min-h-[300px] p-4">
<section className="card min-h-[360px] p-4">
<PanelTitle
icon={<Flame size={14} className="text-[var(--accent)]" />}
title="趋势升温"
meta={`${topics.length} 个话题`}
icon={<Link2 size={14} className="text-[var(--accent)]" />}
title="链接情报"
meta={`${articles.length + tools.length} `}
/>
{topics.length === 0 ? (
<EmptyState text="暂无可识别趋势" />
) : (
<div className="mt-3 space-y-2">
{topics.slice(0, 6).map((topic) => (
<div key={topic.title} className="rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="line-clamp-1 text-[12px] font-medium text-[var(--text)]">{topic.title}</div>
<div className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">{topic.reason}</div>
</div>
<span className={topicStatusClass(topic.status)}>{topicStatusText(topic.status)}</span>
</div>
<div className="mt-1 flex gap-2 text-[10px] text-[var(--text-3)]">
<span>{topic.today_count} </span>
<span>{topic.group_count} </span>
<span> {topic.previous_avg}</span>
</div>
</div>
))}
</div>
)}
<div className="mt-3 grid grid-cols-1 gap-3 2xl:grid-cols-2">
<ResourceColumn icon={<FileText size={13} />} title="文章 / 内容" items={articles.slice(0, 5)} />
<ResourceColumn icon={<Wrench size={13} />} title="工具 / 资源" items={tools.slice(0, 5)} />
</div>
</section>
);
}
function AnomalyPanel({ anomalies }: { anomalies: DashboardAnomalySignal[] }) {
function ResourceColumn({
icon,
title,
items,
}: {
icon: ReactNode;
title: string;
items: DashboardLinkHighlight[];
}) {
return (
<section className="card min-h-[300px] p-4">
<div>
<div className="flex items-center gap-1.5 text-[12px] font-medium text-[var(--text-2)]">
{icon}
{title}
</div>
{items.length === 0 ? (
<div className="mt-3 rounded-md border border-[var(--border-soft)] px-3 py-8 text-center text-[12px] text-[var(--text-3)]">
</div>
) : (
<div className="mt-2 space-y-1.5">
{items.map((item) => (
<a
key={item.url}
href={item.url}
target="_blank"
rel="noreferrer"
className="block rounded-md px-2 py-2 transition-colors hover:bg-[var(--surface-2)]"
>
<div className="flex items-start justify-between gap-2">
<div className="line-clamp-2 text-[12px] font-medium leading-snug text-[var(--text)]">
{item.title}
</div>
<ExternalLink size={12} className="mt-0.5 shrink-0 text-[var(--text-3)]" />
</div>
<div className="mt-1 flex min-w-0 items-center gap-2 text-[10px] text-[var(--text-3)]">
<span className={sourceClass(item.source)}>{sourceLabel(item.source)}</span>
<span className="truncate">{item.domain}</span>
<span>{item.group_count} </span>
<span>{item.count} </span>
</div>
</a>
))}
</div>
)}
</div>
);
}
function sourceLabel(source: string): string {
if (source === 'wechat_raw') return '微信原文';
if (source === 'public_search') return '公开补链';
if (source === 'manual') return '手动补链';
return '网页链接';
}
function sourceClass(source: string): string {
const base = 'shrink-0 rounded px-1.5 py-0.5';
if (source === 'wechat_raw') return `${base} bg-[var(--accent-soft)] text-[var(--accent)]`;
if (source === 'public_search' || source === 'manual') return `${base} bg-[var(--warn-soft)] text-[var(--warn)]`;
return `${base} bg-[var(--surface-2)] text-[var(--text-3)]`;
}
function WatchPanel({
date,
anomalies,
people,
}: {
date: string;
anomalies: DashboardAnomalySignal[];
people: DashboardPeopleRadar[];
}) {
return (
<section className="card min-h-[360px] p-4">
<PanelTitle
icon={<AlertTriangle size={14} className="text-[var(--warn)]" />}
title="异常信号"
meta={`${anomalies.length} `}
title="需要盯一下"
meta={`${anomalies.length} 个异动`}
/>
{anomalies.length === 0 ? (
<EmptyState text="暂无异常波动" />
) : (
<div className="mt-3 space-y-2">
{anomalies.slice(0, 6).map((item) => {
<div className="mt-3 space-y-2">
{anomalies.length === 0 ? (
<EmptyState compact text="暂无明显异动" />
) : (
anomalies.slice(0, 4).map((item) => {
const body = (
<>
<div className="flex items-start justify-between gap-2">
@@ -281,260 +446,100 @@ function AnomalyPanel({ anomalies }: { anomalies: DashboardAnomalySignal[] }) {
</div>
</>
);
return item.href ? (
<a
key={`${item.kind}:${item.title}`}
href={item.href}
target={item.href.startsWith('http') ? '_blank' : undefined}
rel={item.href.startsWith('http') ? 'noreferrer' : undefined}
className="block rounded-md px-2 py-2 hover:bg-[var(--surface-2)]"
>
if (!item.href) {
return (
<div key={`${item.kind}:${item.title}`} className="rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
{body}
</div>
);
}
if (item.href.startsWith('http')) {
return (
<a
key={`${item.kind}:${item.title}`}
href={item.href}
target="_blank"
rel="noreferrer"
className="block rounded-md px-2 py-2 hover:bg-[var(--surface-2)]"
>
{body}
</a>
);
}
return (
<Link key={`${item.kind}:${item.title}`} href={item.href} className="block rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
{body}
</a>
) : (
<div key={`${item.kind}:${item.title}`} className="rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
{body}
</div>
</Link>
);
})}
</div>
)}
</section>
);
}
})
)}
</div>
function LinkHighlightPanel({ items }: { items: DashboardLinkHighlight[] }) {
return (
<section className="card min-h-[300px] p-4">
<PanelTitle
icon={<Link2 size={14} className="text-[var(--accent)]" />}
title="链接精选"
meta={`${items.length}`}
/>
{items.length === 0 ? (
<EmptyState text="暂无高价值链接" />
) : (
<div className="mt-3 space-y-2">
{items.slice(0, 6).map((item) => (
<a
key={item.url}
href={item.url}
target="_blank"
rel="noreferrer"
className="block rounded-md px-2 py-2 hover:bg-[var(--surface-2)]"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="line-clamp-1 text-[12px] font-medium text-[var(--text)]">{item.title}</div>
<div className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">{item.verdict}</div>
</div>
<span className="signal-chip rounded px-1.5 py-0.5 text-[10px]">
{item.kind === 'tool' ? '工具' : '文章'}
</span>
</div>
<div className="mt-1 flex gap-2 text-[10px] text-[var(--text-3)]">
<span className="truncate">{item.domain}</span>
<span>{item.group_count} </span>
<span>{item.count} </span>
</div>
</a>
))}
</div>
)}
</section>
);
}
function PeopleRadarPanel({
people,
fallback,
}: {
people: DashboardPeopleRadar[];
fallback: DashboardSignalSource[];
}) {
const displayPeople =
people.length > 0
? people
: fallback.map((source) => ({
sender: source.sender,
role: '分享者' as const,
score: source.signal_count,
group_count: source.group_count,
signal_count: source.signal_count,
top_group: source.top_group,
reason: `${source.signal_count} 条高信号`,
}));
return (
<section className="card min-h-[300px] p-4">
<PanelTitle
icon={<UserRoundCheck size={14} className="text-[var(--accent)]" />}
title="人物雷达"
meta={`${displayPeople.length}`}
/>
{displayPeople.length === 0 ? (
<EmptyState text="暂无稳定情报源" />
) : (
<div className="mt-3 space-y-1.5">
{displayPeople.slice(0, 7).map((person, index) => (
<div key={`${person.sender}:${person.top_group}`} className="grid grid-cols-[22px_1fr_42px] items-center gap-2 rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
<span className="rounded bg-[var(--surface-2)] py-0.5 text-center text-[10px] tabular-nums text-[var(--text-3)]">
{index + 1}
</span>
<div className="min-w-0">
<div className="mt-4 border-t border-[var(--border-soft)] pt-3">
<div className="text-[12px] font-medium text-[var(--text-2)]"></div>
<div className="mt-2 space-y-1.5">
{people.slice(0, 4).map((person) => (
<div key={`${person.sender}:${person.top_group}`} className="rounded-md px-2 py-1.5 hover:bg-[var(--surface-2)]">
<div className="flex items-center justify-between gap-2">
<div className="truncate text-[12px] text-[var(--text)]">{person.sender}</div>
<div className="truncate text-[10px] text-[var(--text-3)]">
{person.role} · {person.reason}
</div>
<div className="text-[11px] tabular-nums text-[var(--accent)]">{person.score}</div>
</div>
<div className="text-right">
<div className="text-[13px] font-semibold tabular-nums text-[var(--accent)]">{person.score}</div>
<div className="text-[10px] text-[var(--text-3)]">{person.group_count} </div>
<div className="mt-0.5 truncate text-[10px] text-[var(--text-3)]">
{person.role} · {person.reason} · {date}
</div>
</div>
))}
</div>
)}
</div>
</section>
);
}
function ContentIdeaPanel({ ideas }: { ideas: DashboardContentIdea[] }) {
return (
<section className="card min-h-[300px] p-4">
<PanelTitle
icon={<Lightbulb size={14} className="text-[var(--warn)]" />}
title="内容选题"
meta={`${ideas.length}`}
/>
{ideas.length === 0 ? (
<EmptyState text="暂无可转化选题" />
) : (
<div className="mt-3 space-y-2">
{ideas.slice(0, 6).map((idea) => (
<div key={`${idea.suggested_channel}:${idea.title}`} className="rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
<div className="flex items-start justify-between gap-2">
<div className="line-clamp-2 text-[12px] font-medium leading-snug text-[var(--text)]">{idea.title}</div>
<span className="rounded bg-[var(--surface-2)] px-1.5 py-0.5 text-[10px] text-[var(--text-2)]">
{idea.suggested_channel}
</span>
</div>
<div className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">{idea.angle}</div>
<div className="mt-1 text-[10px] text-[var(--text-3)]">{idea.evidence}</div>
</div>
))}
</div>
)}
</section>
);
function mergeSignals(actions: DashboardActionItem[], mustRead: DashboardSignalItem[]) {
const seen = new Set<string>();
const out: Array<DashboardSignalItem | DashboardActionItem> = [];
for (const item of [...actions.slice(0, 4), ...mustRead]) {
const key = `${item.chatroom_id}:${item.local_id}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
function MustReadPanel({ date, items }: { date: string; items: DashboardSignalItem[] }) {
return (
<section className="card min-h-[300px] p-4">
<PanelTitle
icon={<Radar size={14} className="text-[var(--accent)]" />}
title="最值得关注"
meta={`${items.length} 条高信号`}
/>
{items.length === 0 ? (
<EmptyState text="暂无高信号消息" />
) : (
<div className="mt-3 space-y-1">
{items.slice(0, 5).map((item, index) => (
<SignalRow key={`${item.chatroom_id}:${item.local_id}`} item={item} date={date} rank={index + 1} />
))}
</div>
)}
</section>
);
}
function SignalRow({
item,
date,
rank,
}: {
item: DashboardSignalItem;
date: string;
rank: number;
}) {
return (
<Link
href={`/groups/${encodeURIComponent(item.chatroom_id)}?date=${date}`}
className="grid grid-cols-[22px_1fr_16px] items-start gap-2 rounded-md px-2 py-2 transition-colors hover:bg-[var(--surface-2)]"
>
<span className="rounded bg-[var(--surface-2)] py-0.5 text-center text-[10px] tabular-nums text-[var(--text-3)]">
{rank}
</span>
<span className="min-w-0">
<span className="flex min-w-0 items-start justify-between gap-2">
<span className="line-clamp-1 text-[12px] font-medium text-[var(--text)]">{item.title}</span>
<span className="shrink-0 rounded bg-[var(--surface-2)] px-1.5 py-0.5 text-[10px] tabular-nums text-[var(--text-3)]">
{item.score}
</span>
</span>
<span className="mt-1 flex min-w-0 items-center gap-1.5 text-[10px] text-[var(--text-3)]">
<span className="truncate">
{item.chat_name} · {item.sender}
</span>
<span className="shrink-0 tabular-nums">{item.time.slice(11)}</span>
</span>
<span className="mt-1 flex flex-wrap gap-1">
{item.reasons.slice(0, 3).map((reason) => (
<span
key={reason}
className="signal-chip rounded px-1.5 py-0.5 text-[10px]"
title={reasonExplanation(reason)}
>
{reason}
</span>
))}
</span>
</span>
<ExternalLink size={12} className="mt-0.5 text-[var(--text-3)]" />
</Link>
);
function buildSummary(
data: DashboardIntelligence,
articles: DashboardLinkHighlight[],
tools: DashboardLinkHighlight[],
): string {
const lines = [`${data.date || '今日'} 情报队列`];
lines.push(`消息:${data.must_read.slice(0, 2).map((item) => item.title).join('') || '暂无'}`);
lines.push(`文章:${articles.slice(0, 2).map((item) => item.title).join('') || '暂无'}`);
lines.push(`工具:${tools.slice(0, 2).map((item) => item.title).join('') || '暂无'}`);
lines.push(`异动:${data.anomalies.slice(0, 2).map((item) => item.title).join('') || '暂无'}`);
return lines.join('\n');
}
function PanelTitle({ icon, title, meta }: { icon: ReactNode; title: string; meta: string }) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
{icon}
{title}
</div>
<div className="text-[11px] text-[var(--text-3)]">{meta}</div>
<div className="shrink-0 text-[11px] text-[var(--text-3)]">{meta}</div>
</div>
);
}
function EmptyState({ text }: { text: string }) {
function EmptyState({ text, compact }: { text: string; compact?: boolean }) {
return (
<div className="flex h-[220px] items-center justify-center text-[12px] text-[var(--text-3)]">
<div className={`flex items-center justify-center text-[12px] text-[var(--text-3)] ${compact ? 'h-[96px]' : 'h-[220px]'}`}>
{text}
</div>
);
}
function buildDailySummary(data: DashboardIntelligence): string {
const lines = [`${data.date || '今日'} 情报摘要`];
lines.push(`出手:${data.action_items.slice(0, 3).map((item) => `${item.action}${item.title}`).join('') || '暂无'}`);
lines.push(`趋势:${data.topic_lifecycle.slice(0, 3).map((topic) => `${topic.title}(${topicStatusText(topic.status)})`).join('') || '暂无'}`);
lines.push(
`链接:${data.link_highlights.slice(0, 3).map((item) => item.title).join('') || '暂无'}`,
);
lines.push(
`异常:${data.anomalies.slice(0, 2).map((item) => item.title).join('') || '暂无'}`,
);
return lines.join('\n');
}
function urgencyText(urgency: DashboardActionItem['urgency']): string {
if (urgency === 'high') return '高';
if (urgency === 'medium') return '中';
return '低';
}
function urgencyClass(urgency: DashboardActionItem['urgency']): string {
const base = 'shrink-0 rounded px-1.5 py-0.5 text-[10px]';
if (urgency === 'high') return `${base} bg-[var(--warn-soft)] text-[var(--warn)]`;
@@ -542,21 +547,6 @@ function urgencyClass(urgency: DashboardActionItem['urgency']): string {
return `${base} bg-[var(--surface-2)] text-[var(--text-3)]`;
}
function topicStatusText(status: DashboardTopicLifecycle['status']): string {
if (status === 'spreading') return '扩散';
if (status === 'rising') return '升温';
if (status === 'cooling') return '退潮';
return '高热';
}
function topicStatusClass(status: DashboardTopicLifecycle['status']): string {
const base = 'shrink-0 rounded px-1.5 py-0.5 text-[10px]';
if (status === 'spreading') return `${base} bg-[var(--accent-soft)] text-[var(--accent)]`;
if (status === 'rising') return `${base} bg-[var(--warn-soft)] text-[var(--warn)]`;
if (status === 'cooling') return `${base} bg-[var(--surface-2)] text-[var(--text-3)]`;
return `${base} bg-[var(--surface-2)] text-[var(--text-2)]`;
}
function severityText(severity: DashboardAnomalySignal['severity']): string {
if (severity === 'high') return '高';
if (severity === 'medium') return '中';
@@ -569,15 +559,3 @@ function severityClass(severity: DashboardAnomalySignal['severity']): string {
if (severity === 'medium') return `${base} bg-[var(--accent-soft)] text-[var(--accent)]`;
return `${base} bg-[var(--surface-2)] text-[var(--text-3)]`;
}
function reasonExplanation(reason: string): string {
const map: Record<string, string> = {
'机会/需求': '包含合作、采购、报名、求推荐或找资源等可行动信号',
'工具/产品': '提到了工具、产品、模型、插件、项目或技术栈',
: '包含可跳转链接,适合进入原文或资源查看',
: '含有联系、报名、评估、试用、帮忙等行动线索',
: '消息长度较高,可能包含完整观点或经验复盘',
: '包含明确问题,可能适合回复或继续追踪',
};
return map[reason] ?? reason;
}
+5 -18
View File
@@ -8,11 +8,10 @@ import {
Folder,
Inbox,
LayoutDashboard,
Activity,
Sparkles,
Link2,
Settings,
} from 'lucide-react';
import ThemeToggle from './ThemeToggle';
type Category = {
id: number;
@@ -68,21 +67,16 @@ export default function Sidebar() {
}, []);
return (
<aside className="flex h-screen w-[236px] shrink-0 flex-col border-r border-[var(--border-soft)] bg-[rgba(10,16,12,0.86)] backdrop-blur">
<aside className="flex h-screen w-[236px] shrink-0 flex-col border-r border-[var(--border-soft)] bg-[var(--sidebar-bg)] backdrop-blur">
<div className="border-b border-[var(--border-soft)] px-4 py-4">
<div className="flex items-center justify-between">
<Link href="/" className="min-w-0">
<div className="report-kicker">WeChat Radar</div>
<div className="report-kicker">Qiaomu Radar</div>
<div className="mt-1 text-[15px] font-semibold tracking-wide text-[var(--text)]">
</div>
</Link>
<button
className="rounded-md p-1 text-[var(--text-3)] transition-colors hover:bg-[var(--surface-2)] hover:text-[var(--text)]"
aria-label="设置"
>
<Settings size={16} />
</button>
<ThemeToggle />
</div>
<div className="mt-2 text-[11px] text-[var(--text-3)]"> · </div>
</div>
@@ -95,13 +89,6 @@ export default function Sidebar() {
badge="Brief"
active={pathname === '/'}
/>
<NavItem
href="/signals"
icon={<Activity size={15} />}
label="信号流"
badge="Live"
active={pathname === '/signals'}
/>
<NavItem
href="/topics"
icon={<Sparkles size={15} />}
+98
View File
@@ -0,0 +1,98 @@
'use client';
import { Moon, Sun } from 'lucide-react';
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
const STORAGE_KEY = 'wechat-radar-theme-v1';
export default function ThemeToggle() {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'light';
return localStorage.getItem(STORAGE_KEY) === 'dark' ? 'dark' : 'light';
});
useEffect(() => {
applyTheme(theme);
}, [theme]);
const toggle = () => {
const next = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
};
const title = theme === 'dark' ? '切换到浅色主题' : '切换到深色主题';
const Icon = theme === 'dark' ? Sun : Moon;
return (
<button
type="button"
className="control-surface inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-2)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
onClick={toggle}
aria-label={title}
title={title}
>
<Icon size={15} />
</button>
);
}
function applyTheme(theme: Theme) {
document.documentElement.dataset.theme = theme;
document.body.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
for (const [key, value] of Object.entries(theme === 'dark' ? DARK_VARS : LIGHT_VARS)) {
document.documentElement.style.setProperty(key, value);
}
localStorage.setItem(STORAGE_KEY, theme);
}
const LIGHT_VARS = {
'--bg': '#f1efea',
'--bg-2': '#e8e5de',
'--surface': '#faf9f6',
'--surface-2': '#f1eee8',
'--surface-3': '#e5dfd6',
'--border': '#d8d2c8',
'--border-soft': 'rgba(46, 42, 34, 0.12)',
'--text': '#171814',
'--text-2': '#5e6459',
'--text-3': '#8b8b80',
'--accent': '#28745b',
'--accent-2': '#3a9d71',
'--accent-soft': 'rgba(40, 116, 91, 0.12)',
'--warn': '#9c6728',
'--warn-soft': 'rgba(156, 103, 40, 0.12)',
'--danger': '#b34242',
'--danger-soft': 'rgba(179, 66, 66, 0.12)',
'--shadow': '0 18px 55px rgba(55, 47, 34, 0.09)',
'--chrome-bg': 'rgba(250, 249, 246, 0.86)',
'--sidebar-bg': 'rgba(248, 246, 239, 0.92)',
'--control-bg': 'rgba(250, 249, 246, 0.78)',
'--input-scheme': 'light',
};
const DARK_VARS = {
'--bg': '#080b0a',
'--bg-2': '#0d1110',
'--surface': '#111511',
'--surface-2': '#171c17',
'--surface-3': '#20261f',
'--border': '#293029',
'--border-soft': 'rgba(175, 190, 167, 0.13)',
'--text': '#eef2eb',
'--text-2': '#b6bdb1',
'--text-3': '#80887d',
'--accent': '#7dd3a8',
'--accent-2': '#46b978',
'--accent-soft': 'rgba(125, 211, 168, 0.13)',
'--warn': '#d5a253',
'--warn-soft': 'rgba(213, 162, 83, 0.14)',
'--danger': '#df6b6b',
'--danger-soft': 'rgba(223, 107, 107, 0.14)',
'--shadow': '0 18px 48px rgba(0, 0, 0, 0.34)',
'--chrome-bg': 'rgba(8, 11, 10, 0.84)',
'--sidebar-bg': 'rgba(8, 11, 10, 0.9)',
'--control-bg': 'rgba(17, 22, 18, 0.86)',
'--input-scheme': 'dark',
};
+9 -25
View File
@@ -1,9 +1,9 @@
'use client';
import { Calendar, RefreshCw, Database } from 'lucide-react';
import GlobalSearch from './GlobalSearch';
export type RangeKey = 'day' | 'week' | 'month' | 'quarter' | 'year' | 'custom';
export type RefreshMode = 'auto' | 'hour' | 'day' | 'week';
const RANGES: { key: RangeKey; label: string }[] = [
{ key: 'day', label: '日' },
@@ -11,14 +11,6 @@ const RANGES: { key: RangeKey; label: string }[] = [
{ key: 'month', label: '月' },
{ key: 'quarter', label: '季' },
{ key: 'year', label: '年' },
{ key: 'custom', label: '自定义' },
];
const MODES: { key: RefreshMode; label: string }[] = [
{ key: 'auto', label: '自动' },
{ key: 'hour', label: '时' },
{ key: 'day', label: '日' },
{ key: 'week', label: '周' },
];
export default function TopBar({
@@ -26,8 +18,6 @@ export default function TopBar({
date,
onRangeChange,
onDateChange,
mode,
onModeChange,
rescanning,
onRescan,
onFullSync,
@@ -37,36 +27,38 @@ export default function TopBar({
date: string;
onRangeChange: (r: RangeKey) => void;
onDateChange: (date: string) => void;
mode: RefreshMode;
onModeChange: (m: RefreshMode) => void;
rescanning: boolean;
onRescan: () => void;
onFullSync?: () => void;
rescanInfo?: string;
}) {
return (
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
<div className="flex items-center justify-between gap-4 border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
<div>
<div className="report-kicker">Daily Intelligence</div>
<div className="mt-1 text-[16px] font-semibold tracking-wide"> · </div>
<div className="mt-1 text-[16px] font-semibold tracking-wide"> · </div>
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
{rescanInfo ?? '尚未扫描,点击「重扫」加载数据'}
</div>
</div>
<div className="flex items-center gap-2">
<GlobalSearch />
<div className="control-surface flex items-center gap-1.5 rounded-md px-2.5 py-1.5">
<Calendar size={13} className="text-[var(--text-3)]" />
<input
type="date"
value={date}
onChange={(e) => onDateChange(e.target.value)}
className="bg-transparent text-[12px] outline-none [color-scheme:dark]"
title="按日期查看微信雷达"
className="theme-date-input min-w-[128px] bg-transparent text-[12px] outline-none"
title="按日期查看驾驶舱"
/>
</div>
<SegGroup>
<span className="border-r border-[var(--border-soft)] px-2 py-1 text-[11px] text-[var(--text-3)]">
</span>
{RANGES.map((r) => (
<SegBtn key={r.key} active={range === r.key} onClick={() => onRangeChange(r.key)}>
{r.label}
@@ -74,14 +66,6 @@ export default function TopBar({
))}
</SegGroup>
<SegGroup>
{MODES.map((m) => (
<SegBtn key={m.key} active={mode === m.key} onClick={() => onModeChange(m.key)}>
{m.label}
</SegBtn>
))}
</SegGroup>
{onFullSync && (
<button
className="btn"