Initial open source release

This commit is contained in:
joeseesun
2026-05-24 13:42:56 +08:00
commit 8abb29e13e
75 changed files with 13298 additions and 0 deletions
+361
View File
@@ -0,0 +1,361 @@
'use client';
import { useEffect, useMemo, useState, use } from 'react';
import dynamicImport from 'next/dynamic';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import Sidebar from '@/components/Sidebar';
import MessageContent from '@/components/MessageContent';
import { ArrowLeft, BarChart3, Calendar, History, ListFilter, MessageSquare, Star, Trophy } from 'lucide-react';
import type { EChartsOption } from 'echarts';
const ReactECharts = dynamicImport(() => import('echarts-for-react'), { ssr: false });
export const dynamic = 'force-dynamic';
type DailyHistory = { date: string; total: number };
type Detail = {
ok: boolean;
chatroom_id: string;
date: string;
stats: {
chat: string;
total: number;
by_hour: Array<{ hour: number; count: number }>;
by_type: Array<{ type: string; count: number }>;
top_senders: Array<{ sender: string; count: number }>;
} | null;
recent: Array<{
local_id: number;
sender: string;
content: string;
time: string;
timestamp: number;
type: string;
}>;
daily_history: DailyHistory[];
};
export default function GroupDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const chatroomId = decodeURIComponent(id);
const searchParams = useSearchParams();
const requestedDate = searchParams.get('date');
const today = useMemo(() => {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}, []);
const [date, setDate] = useState(requestedDate ?? today);
const [data, setData] = useState<Detail | null>(null);
const [fav, setFav] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const load = async (d: string) => {
setLoading(true);
setErr(null);
try {
const r = await fetch(`/api/group/${encodeURIComponent(chatroomId)}?date=${d}&limit=500`);
const j = (await r.json()) as Detail;
if (!j.ok) {
setErr('详情加载失败');
} else {
setData(j);
}
} catch (e) {
setErr(e instanceof Error ? e.message : '未知错误');
} finally {
setLoading(false);
}
};
useEffect(() => {
queueMicrotask(() => void load(date));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatroomId, date]);
useEffect(() => {
if (requestedDate || !data || date !== today || (data.stats?.total ?? 0) > 0) return;
const latest = data.daily_history
.filter((d) => d.total > 0)
.sort((a, b) => b.date.localeCompare(a.date))[0];
if (latest && latest.date !== date) queueMicrotask(() => setDate(latest.date));
}, [data, date, requestedDate, today]);
useEffect(() => {
(async () => {
try {
const r = await fetch(`/api/group-tags?chatroom_id=${encodeURIComponent(chatroomId)}`);
const j = await r.json();
if (j.ok && Array.isArray(j.group_ids)) setFav(false); // tags only, fav read separately if needed
} catch {}
})();
}, [chatroomId]);
const toggleFav = async () => {
const next = !fav;
setFav(next);
await fetch('/api/group-tags', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ chatroom_id: chatroomId, fav: next }),
});
};
const hourOption: EChartsOption | null = data?.stats
? {
grid: { top: 20, right: 16, bottom: 28, left: 36 },
tooltip: {
trigger: 'axis',
backgroundColor: '#101812',
borderColor: '#27342c',
textStyle: { color: '#edf1e8' },
},
xAxis: {
type: 'category',
data: data.stats.by_hour.map((h) => `${h.hour}:00`),
axisLine: { lineStyle: { color: '#27342c' } },
axisLabel: { color: '#737f75', fontSize: 10 },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: 'rgba(154,174,158,0.12)' } },
axisLabel: { color: '#737f75', fontSize: 10 },
},
series: [
{
type: 'bar',
data: data.stats.by_hour.map((h) => h.count),
itemStyle: { color: '#7dd3a8' },
barWidth: 12,
},
],
}
: null;
const dailyOption: EChartsOption | null =
data?.daily_history && data.daily_history.length > 0
? {
grid: { top: 20, right: 16, bottom: 30, left: 36 },
tooltip: {
trigger: 'axis',
backgroundColor: '#101812',
borderColor: '#27342c',
textStyle: { color: '#edf1e8' },
},
xAxis: {
type: 'category',
data: data.daily_history.map((d) => d.date.slice(5)),
axisLine: { lineStyle: { color: '#27342c' } },
axisLabel: { color: '#737f75', fontSize: 10 },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: 'rgba(154,174,158,0.12)' } },
axisLabel: { color: '#737f75', fontSize: 10 },
},
series: [
{
type: 'bar',
data: data.daily_history.map((d) => ({
value: d.total,
itemStyle: { color: d.date === date ? '#7dd3a8' : '#28372f' },
})),
barWidth: 14,
},
],
}
: null;
const dateOptions = useMemo(() => {
if (!data?.daily_history) return [];
return data.daily_history
.filter((d) => d.total > 0 || d.date === date)
.map((d) => d.date)
.sort()
.reverse();
}, [data, date]);
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex flex-1 flex-col overflow-hidden">
<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 gap-3 min-w-0">
<Link href="/" className="shrink-0 text-[var(--text-3)] hover:text-[var(--text)]">
<ArrowLeft size={16} />
</Link>
<div className="min-w-0">
<div className="report-kicker">Group Brief</div>
<div className="truncate text-[15px] font-semibold">
{data?.stats?.chat ?? (loading ? '加载中…' : chatroomId)}
</div>
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
{date} · {data?.stats?.total ?? 0} · {data?.daily_history?.length ?? 0}
</div>
</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)]" />
{dateOptions.length > 0 ? (
<select
value={date}
onChange={(e) => setDate(e.target.value)}
className="bg-transparent text-[12px] outline-none"
>
{!dateOptions.includes(date) && <option value={date}>{date}</option>}
{dateOptions.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
) : (
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="bg-transparent text-[12px] outline-none [color-scheme:dark]"
/>
)}
</div>
<button className={`btn ${fav ? 'btn-warn' : ''}`} onClick={toggleFav}>
<Star size={13} />
{fav ? '已收藏' : '收藏'}
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5">
{err && <div className="card p-4 text-[12px] text-[var(--danger)]">{err}</div>}
{/* 历史日活跃柱图 */}
{dailyOption && (
<div className="card p-5">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
<History size={14} className="text-[var(--accent)]" />
</div>
<div className="text-[11px] text-[var(--text-3)]">
{data!.daily_history.length} ·
</div>
</div>
<ReactECharts
option={dailyOption}
style={{ height: 160 }}
onEvents={{
click: (e: { name: string }) => {
const matched = data?.daily_history.find((d) => d.date.slice(5) === e.name);
if (matched) setDate(matched.date);
},
}}
/>
</div>
)}
{/* 当日 24 小时分布 */}
{hourOption && (data?.stats?.total ?? 0) > 0 && (
<div className="card mt-4 p-5">
<div className="mb-2 flex items-center gap-1.5 text-[14px] font-semibold">
<BarChart3 size={14} className="text-[var(--accent)]" />
{date} 24
</div>
<ReactECharts option={hourOption} style={{ height: 200 }} />
</div>
)}
{/* Top 发言人 + 消息类型 */}
{data?.stats && data.stats.total > 0 && (
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="card p-5">
<div className="mb-3 flex items-center gap-1.5 text-[14px] font-semibold">
<Trophy size={14} className="text-[var(--warn)]" />
Top
</div>
<div className="space-y-1">
{data.stats.top_senders.slice(0, 12).map((s, i) => (
<div
key={`${s.sender}-${i}`}
className="flex items-center justify-between text-[13px]"
>
<span className="truncate text-[var(--text-2)]">
{i + 1}. {s.sender}
</span>
<span className="tabular-nums text-[var(--text)]">{s.count}</span>
</div>
))}
</div>
</div>
<div className="card p-5">
<div className="mb-3 flex items-center gap-1.5 text-[14px] font-semibold">
<ListFilter size={14} className="text-[var(--accent)]" />
</div>
<div className="space-y-1">
{data.stats.by_type.map((t, i) => (
<div
key={`${t.type}-${i}`}
className="flex items-center justify-between text-[13px]"
>
<span className="text-[var(--text-2)]">{t.type}</span>
<span className="tabular-nums text-[var(--text)]">{t.count}</span>
</div>
))}
</div>
</div>
</div>
)}
{/* 当日完整消息列表 */}
<div className="card mt-4 overflow-hidden">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] px-5 py-3">
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
<MessageSquare size={14} className="text-[var(--accent)]" />
{date}
</div>
<div className="text-[11px] text-[var(--text-3)]">
{loading ? '加载中…' : `${data?.recent.length ?? 0}`}
</div>
</div>
{!loading && data?.recent && data.recent.length === 0 ? (
<div className="py-12 text-center text-[12px] text-[var(--text-3)]">
</div>
) : (
<div className="divide-y divide-[var(--border-soft)]">
{(data?.recent ?? []).map((m) => (
<div
key={m.local_id}
className="grid grid-cols-[120px_1fr_60px_70px] gap-3 px-5 py-2 text-[12px] hover:bg-[var(--surface-2)]"
>
<span className="truncate font-medium text-[var(--text)]">{m.sender}</span>
<div className="text-[var(--text-2)]">
<MessageContent content={m.content} chatroomId={chatroomId} />
</div>
<span className="text-right text-[10px] text-[var(--text-3)]">{m.type}</span>
<span className="text-right text-[var(--text-3)] tabular-nums">
{m.time.slice(11)}
</span>
</div>
))}
</div>
)}
</div>
</div>
</main>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
'use client';
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import Sidebar from '@/components/Sidebar';
import { Star, ChevronRight, Search } from 'lucide-react';
type Group = {
chatroom_id: string;
name: string;
summary: string;
time: string;
timestamp: number;
unread: number;
is_favorite: boolean;
group_ids: number[];
};
type SessionsResp = {
ok: boolean;
total: number;
groups: Group[];
categories: Array<{ id: number; name: string; color: string; emoji: string | null }>;
};
export default function GroupsListPage() {
return (
<Suspense fallback={<GroupsListFallback />}>
<GroupsListContent />
</Suspense>
);
}
function GroupsListContent() {
const params = useSearchParams();
const filter = params.get('filter') ?? 'all';
const groupId = params.get('group_id');
const [data, setData] = useState<SessionsResp | null>(null);
const [q, setQ] = useState('');
const [bumping, setBumping] = useState<string | null>(null);
const reload = useCallback(async () => {
const r = await fetch('/api/sessions');
setData(await r.json());
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch('/api/sessions');
const json = (await r.json()) as SessionsResp;
if (!cancelled) setData(json);
})();
return () => {
cancelled = true;
};
}, []);
const filtered = useMemo(() => {
if (!data) return [];
let list = data.groups;
if (filter === 'favorites') list = list.filter((g) => g.is_favorite);
if (filter === 'unsorted') list = list.filter((g) => g.group_ids.length === 0);
if (filter === 'group' && groupId)
list = list.filter((g) => g.group_ids.includes(Number(groupId)));
if (q.trim()) {
const k = q.trim().toLowerCase();
list = list.filter(
(g) => g.name.toLowerCase().includes(k) || g.summary.toLowerCase().includes(k),
);
}
return [...list].sort((a, b) => b.timestamp - a.timestamp);
}, [data, filter, groupId, q]);
const title = useMemo(() => {
if (filter === 'favorites') return '收藏的群';
if (filter === 'unsorted') return '未分组的群';
if (filter === 'group' && groupId && data) {
const c = data.categories.find((c) => c.id === Number(groupId));
return c ? `分组:${c.emoji ?? ''} ${c.name}` : '分组';
}
return '所有群';
}, [filter, groupId, data]);
const toggleFav = async (chatroomId: string, current: boolean) => {
setBumping(chatroomId);
await fetch('/api/group-tags', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ chatroom_id: chatroomId, fav: !current }),
});
setBumping(null);
reload();
};
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex flex-1 flex-col overflow-hidden">
<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">Group Directory</div>
<div className="text-[15px] font-semibold">{title}</div>
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
{filtered.length} / {data?.total ?? 0}
</div>
</div>
<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={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索群名或最近消息…"
className="w-60 bg-transparent text-[12px] outline-none placeholder:text-[var(--text-3)]"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{!data ? (
<div className="py-20 text-center text-[12px] text-[var(--text-3)]"></div>
) : filtered.length === 0 ? (
<div className="py-20 text-center text-[12px] text-[var(--text-3)]"></div>
) : (
<div className="space-y-1">
{filtered.map((g) => (
<div
key={g.chatroom_id}
className="group grid grid-cols-[1fr_140px_60px_24px] items-center gap-3 rounded-md border border-transparent px-3 py-2.5 text-[13px] hover:border-[var(--border-soft)] hover:bg-[var(--surface-2)]"
>
<Link
href={`/groups/${encodeURIComponent(g.chatroom_id)}`}
className="min-w-0"
>
<div className="flex items-center gap-2">
<div className="truncate font-medium text-[var(--text)]">{g.name}</div>
{g.unread > 0 && (
<span className="shrink-0 rounded bg-[var(--danger)] px-1.5 py-0.5 text-[10px] font-semibold text-white">
{g.unread}
</span>
)}
</div>
<div className="truncate text-[11px] text-[var(--text-3)]">{g.summary}</div>
</Link>
<div className="text-right text-[11px] text-[var(--text-3)]">{g.time}</div>
<button
className={bumping === g.chatroom_id ? 'opacity-50' : ''}
onClick={() => toggleFav(g.chatroom_id, g.is_favorite)}
title={g.is_favorite ? '取消收藏' : '加入收藏'}
>
<Star
size={14}
className={
g.is_favorite ? 'fill-[var(--warn)] text-[var(--warn)]' : 'text-[var(--text-3)] hover:text-[var(--text)]'
}
/>
</button>
<Link
href={`/groups/${encodeURIComponent(g.chatroom_id)}`}
className="text-[var(--text-3)] hover:text-[var(--text)]"
>
<ChevronRight size={14} />
</Link>
</div>
))}
</div>
)}
</div>
</main>
</div>
);
}
function GroupsListFallback() {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex flex-1 flex-col overflow-hidden">
<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">Group Directory</div>
<div className="text-[15px] font-semibold"></div>
<div className="mt-0.5 text-[11px] text-[var(--text-3)]"></div>
</div>
<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)]" />
<div className="h-4 w-60" />
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="py-20 text-center text-[12px] text-[var(--text-3)]"></div>
</div>
</main>
</div>
);
}