mirror of
https://github.com/joeseesun/wechat-radar.git
synced 2026-09-10 00:28:30 +09:00
Initial open source release
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import Link from 'next/link';
|
||||
import { Flame } from 'lucide-react';
|
||||
|
||||
export interface ActiveGroup {
|
||||
chatroom_id: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
total: number;
|
||||
top_senders: Array<{ sender: string; count: number }>;
|
||||
}
|
||||
|
||||
export default function ActiveGroupsList({ groups }: { groups: ActiveGroup[] }) {
|
||||
const max = groups[0]?.total ?? 1;
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
|
||||
<Flame size={14} className="text-[var(--warn)]" />
|
||||
智能活跃群
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-3)]">
|
||||
去噪后 {groups.length} 个 · 综合排序
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<div className="py-10 text-center text-[12px] text-[var(--text-3)]">
|
||||
暂无数据 · 点击「重扫」加载
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{groups.slice(0, 12).map((g, i) => (
|
||||
<Row key={g.chatroom_id} group={g} rank={i + 1} max={max} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ group, rank, max }: { group: ActiveGroup; rank: number; max: number }) {
|
||||
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;
|
||||
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { PieChart } from 'lucide-react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
|
||||
const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false });
|
||||
|
||||
export interface CategoryStat {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
emoji: string | null;
|
||||
group_count: number;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
type Mode = 'donut' | 'ring' | 'bar' | 'radar';
|
||||
const MODES: { key: Mode; label: string }[] = [
|
||||
{ key: 'donut', label: '同心环' },
|
||||
{ key: 'ring', label: '圆环' },
|
||||
{ key: 'bar', label: '柱状' },
|
||||
{ key: 'radar', label: '雷达' },
|
||||
];
|
||||
|
||||
export default function CategoryChart({ categories }: { categories: CategoryStat[] }) {
|
||||
const [mode, setMode] = useState<Mode>('bar');
|
||||
|
||||
const totalGroups = categories.reduce((s, c) => s + c.group_count, 0);
|
||||
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const data = categories
|
||||
.filter((c) => c.group_count > 0)
|
||||
.map((c) => ({
|
||||
name: c.name,
|
||||
value: c.group_count,
|
||||
itemStyle: { color: c.color },
|
||||
}));
|
||||
|
||||
const baseTooltip = {
|
||||
backgroundColor: '#101812',
|
||||
borderColor: '#27342c',
|
||||
textStyle: { color: '#edf1e8' },
|
||||
} as const;
|
||||
|
||||
if (mode === 'bar') {
|
||||
return {
|
||||
grid: { top: 8, right: 24, bottom: 8, left: 80 },
|
||||
tooltip: { trigger: 'item', ...baseTooltip },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { lineStyle: { color: 'rgba(154,174,158,0.12)' } },
|
||||
axisLabel: { color: '#737f75', fontSize: 10 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: data.map((d) => d.name),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#aab4aa', fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data,
|
||||
barWidth: 12,
|
||||
label: { show: true, position: 'right', color: '#aab4aa', fontSize: 10 },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'donut' || mode === 'ring') {
|
||||
const radius = mode === 'donut' ? ['38%', '70%'] : ['55%', '70%'];
|
||||
return {
|
||||
tooltip: { trigger: 'item', ...baseTooltip },
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: 8,
|
||||
top: 'middle',
|
||||
textStyle: { color: '#aab4aa', fontSize: 10 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius,
|
||||
center: ['38%', '50%'],
|
||||
data,
|
||||
label: { show: false },
|
||||
labelLine: { show: false },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tooltip: baseTooltip,
|
||||
radar: {
|
||||
center: ['50%', '54%'],
|
||||
radius: 88,
|
||||
indicator: data.map((d) => ({
|
||||
name: d.name,
|
||||
max: Math.max(...data.map((x) => x.value), 1),
|
||||
})),
|
||||
axisName: { color: '#aab4aa', fontSize: 10 },
|
||||
splitLine: { lineStyle: { color: '#27342c' } },
|
||||
splitArea: { areaStyle: { color: ['rgba(16,24,18,0.42)'] } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'radar',
|
||||
data: [
|
||||
{
|
||||
value: data.map((d) => d.value),
|
||||
areaStyle: { color: 'rgba(125,211,168,0.2)' },
|
||||
lineStyle: { color: '#7dd3a8' },
|
||||
itemStyle: { color: '#7dd3a8' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [categories, mode]);
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
|
||||
<PieChart size={14} className="text-[var(--accent)]" />
|
||||
分类构成
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-3)]">
|
||||
{categories.length} 类 · {totalGroups} 群
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex gap-1 text-[11px]">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.key}
|
||||
onClick={() => setMode(m.key)}
|
||||
className={`rounded px-2 py-0.5 transition-colors ${
|
||||
mode === m.key
|
||||
? 'bg-[var(--accent-soft)] text-[var(--accent)]'
|
||||
: 'text-[var(--text-3)] hover:text-[var(--text-2)]'
|
||||
}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{categories.length > 0 ? (
|
||||
<ReactECharts option={option} style={{ height: 280 }} />
|
||||
) : (
|
||||
<div className="flex h-[280px] items-center justify-center text-[12px] text-[var(--text-3)]">
|
||||
暂无分类数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Clipboard,
|
||||
ExternalLink,
|
||||
Flame,
|
||||
Lightbulb,
|
||||
Link2,
|
||||
Radar,
|
||||
Target,
|
||||
UserRoundCheck,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface DashboardSignalItem {
|
||||
chatroom_id: string;
|
||||
chat_name: string;
|
||||
local_id: number;
|
||||
sender: string;
|
||||
time: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface DashboardOpportunityItem extends DashboardSignalItem {
|
||||
action: string;
|
||||
}
|
||||
|
||||
export interface DashboardActionItem extends DashboardOpportunityItem {
|
||||
why: string;
|
||||
urgency: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
export interface DashboardSignalSource {
|
||||
sender: string;
|
||||
signal_count: number;
|
||||
group_count: number;
|
||||
top_group: string;
|
||||
last_seen: string;
|
||||
strengths: string[];
|
||||
}
|
||||
|
||||
export interface DashboardTopicLifecycle {
|
||||
title: string;
|
||||
status: 'rising' | 'spreading' | 'hot' | 'cooling';
|
||||
today_count: number;
|
||||
previous_avg: number;
|
||||
group_count: number;
|
||||
reason: string;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export interface DashboardLinkHighlight {
|
||||
kind: 'article' | 'tool';
|
||||
title: string;
|
||||
url: string;
|
||||
domain: string;
|
||||
score: number;
|
||||
verdict: string;
|
||||
count: number;
|
||||
group_count: number;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
export interface DashboardPeopleRadar {
|
||||
sender: string;
|
||||
role: '分享者' | '需求提出者' | '连接者' | '观点源';
|
||||
score: number;
|
||||
group_count: number;
|
||||
signal_count: number;
|
||||
top_group: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface DashboardContentIdea {
|
||||
title: string;
|
||||
angle: string;
|
||||
suggested_channel: '公众号' | 'X' | '小红书' | '博客';
|
||||
evidence: string;
|
||||
source_count: number;
|
||||
}
|
||||
|
||||
export interface DashboardAnomalySignal {
|
||||
kind: 'spike' | 'cross_group' | 'dense_links' | 'quiet_day';
|
||||
title: string;
|
||||
description: string;
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface DashboardIntelligence {
|
||||
date: string;
|
||||
must_read: DashboardSignalItem[];
|
||||
opportunities: DashboardOpportunityItem[];
|
||||
signal_sources: DashboardSignalSource[];
|
||||
action_items: DashboardActionItem[];
|
||||
topic_lifecycle: DashboardTopicLifecycle[];
|
||||
link_highlights: DashboardLinkHighlight[];
|
||||
people_radar: DashboardPeopleRadar[];
|
||||
content_ideas: DashboardContentIdea[];
|
||||
anomalies: DashboardAnomalySignal[];
|
||||
}
|
||||
|
||||
export default function IntelligenceBrief({ intelligence }: { intelligence?: DashboardIntelligence }) {
|
||||
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);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
}
|
||||
|
||||
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(' · ')}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<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({
|
||||
date,
|
||||
items,
|
||||
fallback,
|
||||
}: {
|
||||
date: string;
|
||||
items: DashboardActionItem[];
|
||||
fallback: DashboardOpportunityItem[];
|
||||
}) {
|
||||
const displayItems =
|
||||
items.length > 0
|
||||
? items
|
||||
: fallback.map((item) => ({
|
||||
...item,
|
||||
why: '包含明确需求或行动线索,适合进入上下文判断',
|
||||
urgency: 'medium' as const,
|
||||
}));
|
||||
return (
|
||||
<section className="card min-h-[300px] p-4">
|
||||
<PanelTitle
|
||||
icon={<Target size={14} className="text-[var(--warn)]" />}
|
||||
title="今日值得出手"
|
||||
meta={`${displayItems.length} 条`}
|
||||
/>
|
||||
{displayItems.length === 0 ? (
|
||||
<EmptyState text="暂无明确行动项" />
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{displayItems.slice(0, 6).map((item) => (
|
||||
<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)]"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TopicLifecyclePanel({ topics }: { topics: DashboardTopicLifecycle[] }) {
|
||||
return (
|
||||
<section className="card min-h-[300px] p-4">
|
||||
<PanelTitle
|
||||
icon={<Flame size={14} className="text-[var(--accent)]" />}
|
||||
title="趋势升温"
|
||||
meta={`${topics.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>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnomalyPanel({ anomalies }: { anomalies: DashboardAnomalySignal[] }) {
|
||||
return (
|
||||
<section className="card min-h-[300px] p-4">
|
||||
<PanelTitle
|
||||
icon={<AlertTriangle size={14} className="text-[var(--warn)]" />}
|
||||
title="异常信号"
|
||||
meta={`${anomalies.length} 条`}
|
||||
/>
|
||||
{anomalies.length === 0 ? (
|
||||
<EmptyState text="暂无异常波动" />
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{anomalies.slice(0, 6).map((item) => {
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="line-clamp-1 text-[12px] font-medium text-[var(--text)]">{item.title}</div>
|
||||
<span className={severityClass(item.severity)}>{severityText(item.severity)}</span>
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-[10px] leading-snug text-[var(--text-3)]">
|
||||
{item.description}
|
||||
</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)]"
|
||||
>
|
||||
{body}
|
||||
</a>
|
||||
) : (
|
||||
<div key={`${item.kind}:${item.title}`} className="rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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="truncate text-[12px] text-[var(--text)]">{person.sender}</div>
|
||||
<div className="truncate text-[10px] text-[var(--text-3)]">
|
||||
{person.role} · {person.reason}
|
||||
</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>
|
||||
</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 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 PanelTitle({ icon, title, meta }: { icon: ReactNode; title: string; meta: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="flex h-[220px] items-center justify-center text-[12px] text-[var(--text-3)]">
|
||||
{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)]`;
|
||||
if (urgency === 'medium') return `${base} bg-[var(--accent-soft)] text-[var(--accent)]`;
|
||||
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 '中';
|
||||
return '低';
|
||||
}
|
||||
|
||||
function severityClass(severity: DashboardAnomalySignal['severity']): string {
|
||||
const base = 'shrink-0 rounded px-1.5 py-0.5 text-[10px]';
|
||||
if (severity === 'high') return `${base} bg-[var(--warn-soft)] text-[var(--warn)]`;
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
const IMG_RE = /\[图片\]\s*local_id=(\d+)/g;
|
||||
|
||||
export default function MessageContent({
|
||||
content,
|
||||
chatroomId,
|
||||
}: {
|
||||
content: string;
|
||||
chatroomId: string;
|
||||
}) {
|
||||
if (!content) return null;
|
||||
|
||||
// 没有图片占位符直接返回文本
|
||||
if (!content.includes('[图片]')) {
|
||||
return <span className="whitespace-pre-wrap break-words">{content}</span>;
|
||||
}
|
||||
|
||||
// 切片:文本 + 图片 + 文本 + ...
|
||||
const parts: Array<{ type: 'text'; v: string } | { type: 'img'; localId: number }> = [];
|
||||
let last = 0;
|
||||
for (const m of content.matchAll(IMG_RE)) {
|
||||
if (m.index === undefined) continue;
|
||||
if (m.index > last) {
|
||||
parts.push({ type: 'text', v: content.slice(last, m.index) });
|
||||
}
|
||||
parts.push({ type: 'img', localId: Number(m[1]) });
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < content.length) {
|
||||
parts.push({ type: 'text', v: content.slice(last) });
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="whitespace-pre-wrap break-words">
|
||||
{parts.map((p, i) => {
|
||||
if (p.type === 'text') return <span key={i}>{p.v}</span>;
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={i}
|
||||
src={`/api/wx-image?chatroom=${encodeURIComponent(chatroomId)}&local_id=${p.localId}`}
|
||||
alt={`图片 ${p.localId}`}
|
||||
loading="lazy"
|
||||
className="my-1 inline-block max-h-[280px] max-w-full rounded border border-[var(--border)] align-middle"
|
||||
onError={(e) => {
|
||||
const el = e.currentTarget;
|
||||
el.replaceWith(
|
||||
Object.assign(document.createElement('span'), {
|
||||
className: 'text-[var(--text-3)]',
|
||||
textContent: `[图片缺失 local_id=${p.localId}]`,
|
||||
}) as HTMLElement,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
const COLORS = [
|
||||
'#ef4444',
|
||||
'#f97316',
|
||||
'#f59e0b',
|
||||
'#eab308',
|
||||
'#7dd3a8',
|
||||
'#10b981',
|
||||
'#06b6d4',
|
||||
'#0ea5e9',
|
||||
'#6366f1',
|
||||
'#8b5cf6',
|
||||
'#a855f7',
|
||||
'#ec4899',
|
||||
];
|
||||
|
||||
export default function NewGroupModal({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [emoji, setEmoji] = useState('');
|
||||
const [color, setColor] = useState(COLORS[0]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (!name.trim()) {
|
||||
setErr('分组名不能为空');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await fetch('/api/groups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: name.trim(), color, emoji: emoji.trim() || undefined }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!j.ok) {
|
||||
setErr(j.error ?? '创建失败');
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
onCreated();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : '未知错误');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="card w-[400px] p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[15px] font-semibold">新建分组</div>
|
||||
<button onClick={onClose} className="text-[var(--text-3)] hover:text-[var(--text)]">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-[11px] text-[var(--text-3)]">分组名</label>
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="如:AI · 编程"
|
||||
className="control-surface mt-1 w-full rounded-md px-3 py-2 text-[13px] text-[var(--text)] outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-[var(--text-3)]">Emoji(可选)</label>
|
||||
<input
|
||||
value={emoji}
|
||||
onChange={(e) => setEmoji(e.target.value)}
|
||||
placeholder="🤖"
|
||||
maxLength={4}
|
||||
className="control-surface mt-1 w-full rounded-md px-3 py-2 text-[13px] text-[var(--text)] outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-[var(--text-3)]">颜色</label>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
className={`size-6 rounded-full ring-2 transition-all ${
|
||||
color === c ? 'ring-white' : 'ring-transparent'
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => setColor(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{err && <div className="text-[12px] text-[var(--danger)]">{err}</div>}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button className="btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={submit} disabled={busy}>
|
||||
{busy ? '创建中…' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
Star,
|
||||
Folder,
|
||||
Inbox,
|
||||
LayoutDashboard,
|
||||
Activity,
|
||||
Sparkles,
|
||||
Link2,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
|
||||
type Category = {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
emoji: string | null;
|
||||
member_count?: number;
|
||||
};
|
||||
|
||||
type SidebarData = {
|
||||
ok: boolean;
|
||||
total: number;
|
||||
categories: Category[];
|
||||
};
|
||||
|
||||
type DaemonStatus = {
|
||||
ok: boolean;
|
||||
running: boolean;
|
||||
pid?: number;
|
||||
};
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [data, setData] = useState<SidebarData | null>(null);
|
||||
const [daemon, setDaemon] = useState<DaemonStatus | null>(null);
|
||||
const [unsorted, setUnsorted] = useState(0);
|
||||
const [favorites, setFavorites] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const r = await fetch('/api/sessions');
|
||||
const j = await r.json();
|
||||
setData(j);
|
||||
} catch {}
|
||||
try {
|
||||
const r = await fetch('/api/stats?range=week');
|
||||
const j = await r.json();
|
||||
if (j.ok && j.sidebar_counts) {
|
||||
setUnsorted(j.sidebar_counts.unsorted);
|
||||
setFavorites(j.sidebar_counts.favorites);
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const r = await fetch('/api/daemon');
|
||||
const j = await r.json();
|
||||
setDaemon(j);
|
||||
} catch {}
|
||||
};
|
||||
load();
|
||||
const id = setInterval(load, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
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">
|
||||
<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="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>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-[var(--text-3)]">私有看板 · 高信号优先</div>
|
||||
</div>
|
||||
|
||||
<nav className="px-2 pb-2 pt-3">
|
||||
<NavItem
|
||||
href="/"
|
||||
icon={<LayoutDashboard size={15} />}
|
||||
label="看板"
|
||||
badge="Brief"
|
||||
active={pathname === '/'}
|
||||
/>
|
||||
<NavItem
|
||||
href="/signals"
|
||||
icon={<Activity size={15} />}
|
||||
label="信号流"
|
||||
badge="Live"
|
||||
active={pathname === '/signals'}
|
||||
/>
|
||||
<NavItem
|
||||
href="/topics"
|
||||
icon={<Sparkles size={15} />}
|
||||
label="话题雷达"
|
||||
badge="Cross"
|
||||
active={pathname === '/topics'}
|
||||
/>
|
||||
<NavItem
|
||||
href="/links"
|
||||
icon={<Link2 size={15} />}
|
||||
label="链接情报"
|
||||
badge="Link"
|
||||
active={pathname === '/links'}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div className="px-4 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-3)]">
|
||||
Groups
|
||||
</div>
|
||||
<nav className="px-2">
|
||||
<NavItem href="/groups" icon={<Inbox size={15} />} label="所有群" count={data?.total} />
|
||||
<NavItem href="/groups?filter=favorites" icon={<Star size={15} />} label="收藏" count={favorites} />
|
||||
<NavItem
|
||||
href="/groups?filter=unsorted"
|
||||
icon={<Folder size={15} />}
|
||||
label="未分组"
|
||||
count={unsorted}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div className="px-4 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-3)]">
|
||||
Collections
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{(data?.categories ?? []).map((c) => (
|
||||
<CategoryItem key={c.id} category={c} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border-soft)] px-4 py-2 text-[11px] text-[var(--text-3)]">
|
||||
{daemon?.running ? (
|
||||
<span>
|
||||
<span className="inline-block size-2 rounded-full bg-[var(--accent)] mr-1.5 align-middle" />
|
||||
wx-daemon 运行中{daemon.pid ? ` (PID ${daemon.pid})` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<span className="inline-block size-2 rounded-full bg-[var(--danger)] mr-1.5 align-middle" />
|
||||
wx-daemon 未运行
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
href,
|
||||
icon,
|
||||
label,
|
||||
badge,
|
||||
count,
|
||||
active,
|
||||
}: {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
badge?: string;
|
||||
count?: number;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`group relative flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-[13px] transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--accent-soft)] text-[var(--text)]'
|
||||
: 'text-[var(--text-2)] hover:bg-[var(--surface-2)] hover:text-[var(--text)]'
|
||||
}`}
|
||||
>
|
||||
{active && <span className="absolute left-0 top-1/2 h-4 w-0.5 -translate-y-1/2 rounded-full bg-[var(--accent)]" />}
|
||||
<span className="flex items-center gap-2">
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
{badge && (
|
||||
<span className="signal-chip rounded px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{count !== undefined && (
|
||||
<span className="text-[11px] text-[var(--text-3)] tabular-nums">{count}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryItem({ category }: { category: Category }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/groups?filter=group&group_id=${category.id}`}
|
||||
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-[13px] text-[var(--text-2)] transition-colors hover:bg-[var(--surface-2)] hover:text-[var(--text)]"
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: category.color }}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{category.emoji ? `${category.emoji} ` : ''}
|
||||
{category.name}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--text-3)] tabular-nums">
|
||||
{category.member_count ?? 0}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import Link from 'next/link';
|
||||
import { Activity, MessageCircle, AtSign, MoonStar } from 'lucide-react';
|
||||
|
||||
export interface CardsData {
|
||||
active_groups: number;
|
||||
total_groups: number;
|
||||
total_messages: number;
|
||||
mentions: number;
|
||||
silent_groups: number;
|
||||
avg_per_group: number;
|
||||
}
|
||||
|
||||
export default function StatGrid({ cards, days }: { cards?: CardsData; days: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card
|
||||
icon={<Activity size={14} className="text-[var(--accent)]" />}
|
||||
label="活跃群"
|
||||
value={cards?.active_groups ?? '—'}
|
||||
sub={cards ? `共扫 ${cards.total_groups} 个群` : '等待扫描'}
|
||||
/>
|
||||
<Card
|
||||
icon={<MessageCircle size={14} className="text-[var(--accent)]" />}
|
||||
label="总消息"
|
||||
value={cards?.total_messages?.toLocaleString() ?? '—'}
|
||||
sub={
|
||||
cards
|
||||
? `过去 ${days * 24}h · 平均每群 ${cards.avg_per_group} 条`
|
||||
: '等待扫描'
|
||||
}
|
||||
/>
|
||||
<Card
|
||||
icon={<AtSign size={14} className="text-[var(--warn)]" />}
|
||||
label="@ 我的"
|
||||
value={cards?.mentions ?? 0}
|
||||
sub={cards ? '需要回复' : '等待扫描'}
|
||||
accent="warn"
|
||||
href="/mentions"
|
||||
/>
|
||||
<Card
|
||||
icon={<MoonStar size={14} className="text-[var(--text-3)]" />}
|
||||
label="静默群"
|
||||
value={cards?.silent_groups ?? '—'}
|
||||
sub={cards ? `过去 ${days * 24}h 无活动` : '等待扫描'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
accent,
|
||||
href,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: number | string;
|
||||
sub: string;
|
||||
accent?: 'warn';
|
||||
href?: string;
|
||||
}) {
|
||||
const className = `card group relative overflow-hidden px-5 py-4 ${
|
||||
href
|
||||
? 'block transition-colors hover:border-[rgba(213,162,83,0.5)] hover:bg-[var(--surface-2)] focus:outline-none focus:ring-1 focus:ring-[var(--warn)]'
|
||||
: ''
|
||||
}`;
|
||||
const content = (
|
||||
<>
|
||||
<div className={`absolute inset-x-0 top-0 h-px ${accent === 'warn' ? 'bg-[var(--warn)]' : 'bg-[var(--accent)]'} opacity-60`} />
|
||||
<div className="flex items-center justify-between gap-2 text-[12px] text-[var(--text-2)]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-[var(--text-3)]">Metric</span>
|
||||
</div>
|
||||
<div
|
||||
className={`mt-3 text-[34px] font-semibold leading-none tabular-nums ${
|
||||
accent === 'warn' ? 'text-[var(--warn)]' : 'text-[var(--text)]'
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-[var(--text-3)]">{sub}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className={className} aria-label={`查看${label}消息`}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { Calendar, RefreshCw, Database } from 'lucide-react';
|
||||
|
||||
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: '日' },
|
||||
{ key: 'week', label: '周' },
|
||||
{ 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({
|
||||
range,
|
||||
date,
|
||||
onRangeChange,
|
||||
onDateChange,
|
||||
mode,
|
||||
onModeChange,
|
||||
rescanning,
|
||||
onRescan,
|
||||
onFullSync,
|
||||
rescanInfo,
|
||||
}: {
|
||||
range: RangeKey;
|
||||
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>
|
||||
<div className="report-kicker">Daily Intelligence</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">
|
||||
<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="按日期查看微信雷达"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SegGroup>
|
||||
{RANGES.map((r) => (
|
||||
<SegBtn key={r.key} active={range === r.key} onClick={() => onRangeChange(r.key)}>
|
||||
{r.label}
|
||||
</SegBtn>
|
||||
))}
|
||||
</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"
|
||||
onClick={onFullSync}
|
||||
disabled={rescanning}
|
||||
title="一次性同步过去 365 天的所有消息到本地数据库"
|
||||
>
|
||||
<Database size={13} />
|
||||
<span>全量同步</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`btn ${rescanning ? 'btn-warn' : 'btn-primary'}`}
|
||||
onClick={onRescan}
|
||||
disabled={rescanning}
|
||||
>
|
||||
<RefreshCw size={13} className={rescanning ? 'animate-spin' : ''} />
|
||||
<span>{rescanning ? '同步中…' : '重扫'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SegGroup({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="control-surface flex overflow-hidden rounded-md">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SegBtn({
|
||||
children,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`px-2.5 py-1 text-[12px] transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--accent-soft)] text-[var(--accent)]'
|
||||
: 'text-[var(--text-2)] hover:text-[var(--text)]'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { TrendingUp } from 'lucide-react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
|
||||
const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false });
|
||||
|
||||
export interface TrendPoint {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export default function TrendChart({
|
||||
data,
|
||||
peak,
|
||||
avg,
|
||||
total,
|
||||
}: {
|
||||
data: TrendPoint[];
|
||||
peak: TrendPoint;
|
||||
avg: number;
|
||||
total: number;
|
||||
}) {
|
||||
const option = useMemo<EChartsOption>(
|
||||
() => ({
|
||||
grid: { top: 30, right: 24, bottom: 30, left: 50 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: '#101812',
|
||||
borderColor: '#27342c',
|
||||
textStyle: { color: '#edf1e8' },
|
||||
formatter: (params: unknown) => {
|
||||
const arr = params as Array<{ name: string; value: number }>;
|
||||
const p = arr[0];
|
||||
return `<div style="font-size:12px"><div style="color:#aab4aa">${p.name}</div><div style="color:#7dd3a8;font-weight:600;margin-top:2px">消息数:${p.value} 条</div></div>`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((d) => d.date.slice(5)),
|
||||
axisLine: { lineStyle: { color: '#27342c' } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#737f75', fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { color: 'rgba(154,174,158,0.12)' } },
|
||||
axisLabel: { color: '#737f75', fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
data: data.map((d) => d.count),
|
||||
lineStyle: { color: '#7dd3a8', width: 2 },
|
||||
itemStyle: { color: '#7dd3a8' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(125,211,168,0.34)' },
|
||||
{ offset: 1, color: 'rgba(125,211,168,0.02)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
|
||||
<TrendingUp size={14} className="text-[var(--accent)]" />
|
||||
消息走势
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-3)]">
|
||||
过去 {data.length} 天 · {total.toLocaleString()} 条
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex gap-6 text-[11px] text-[var(--text-3)]">
|
||||
<span>
|
||||
峰值 <span className="text-[var(--text)]">{peak.count} 条/天</span>
|
||||
{peak.date && <span className="ml-1 text-[var(--text-3)]">· {peak.date}</span>}
|
||||
</span>
|
||||
<span>
|
||||
均值 <span className="text-[var(--text)]">{avg.toFixed(1)}</span>
|
||||
</span>
|
||||
<span>
|
||||
总计 <span className="text-[var(--text)]">{total.toLocaleString()} 条</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
{data.length > 0 ? (
|
||||
<ReactECharts option={option} style={{ height: 280 }} />
|
||||
) : (
|
||||
<div className="flex h-[280px] items-center justify-center text-[12px] text-[var(--text-3)]">
|
||||
暂无数据 · 点击右上「重扫」加载
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user