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
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { wxSessions } from '@/lib/wx';
import { listGroups, listAllTags, tagGroup } from '@/lib/groups';
import { classifyGroupHeuristic } from '@/lib/group-classifier';
export const dynamic = 'force-dynamic';
interface Suggestion {
chatroom_id: string;
name: string;
summary: string;
current_group_ids: number[];
suggested_group_id: number | null;
suggested_group_name: string | null;
reason: string;
}
const ApplySchema = z.object({
picks: z.array(
z.object({
chatroom_id: z.string().min(1),
group_id: z.number().int().positive(),
}),
),
});
export async function GET() {
const sessions = await wxSessions(500);
const groupSessions = sessions.filter((s) => s.is_group);
const groups = listGroups();
const tags = listAllTags();
const tagged = new Map<string, number[]>();
for (const t of tags) {
const arr = tagged.get(t.chatroom_id) ?? [];
arr.push(t.group_id);
tagged.set(t.chatroom_id, arr);
}
const suggestions: Suggestion[] = groupSessions
.filter((g) => !tagged.has(g.username))
.slice(0, 200)
.map((g) => {
const guess = classifyGroupHeuristic(g.chat, g.summary, groups);
return {
chatroom_id: g.username,
name: g.chat,
summary: g.summary,
current_group_ids: tagged.get(g.username) ?? [],
suggested_group_id: guess?.group_id ?? null,
suggested_group_name: guess?.group_name ?? null,
reason: guess?.reason ?? '未匹配到关键词',
};
});
return NextResponse.json({ ok: true, groups, suggestions });
}
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null);
const parsed = ApplySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: parsed.error.message }, { status: 400 });
}
for (const p of parsed.data.picks) {
tagGroup(p.chatroom_id, p.group_id);
}
return NextResponse.json({ ok: true, applied: parsed.data.picks.length });
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { wxDaemonStatus } from '@/lib/wx';
import { cache, CK } from '@/lib/cache';
export const dynamic = 'force-dynamic';
export async function GET() {
let s = cache.get(CK.daemon()) as Awaited<ReturnType<typeof wxDaemonStatus>> | undefined;
if (!s) {
s = await wxDaemonStatus();
cache.set(CK.daemon(), s, 30);
}
return NextResponse.json({ ok: true, ...s });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { existsSync, statSync } from 'node:fs';
export const dynamic = 'force-dynamic';
export async function GET() {
const dataDir = process.env.WECHAT_RADAR_DATA_DIR ?? join(homedir(), '.wechat-radar');
const dbPath = join(dataDir, 'radar.db');
const dbSize = existsSync(dbPath) ? statSync(dbPath).size : 0;
const counts = {
groups: (db().prepare('SELECT COUNT(*) AS n FROM groups').get() as { n: number }).n,
messages: (db().prepare('SELECT COUNT(*) AS n FROM messages').get() as { n: number }).n,
daily_stats: (db().prepare('SELECT COUNT(*) AS n FROM daily_stats').get() as { n: number }).n,
sync_state: (db().prepare('SELECT COUNT(*) AS n FROM sync_state').get() as { n: number }).n,
};
const topGroups = db().prepare(`
SELECT chatroom_id, COUNT(*) AS n FROM messages GROUP BY chatroom_id ORDER BY n DESC LIMIT 5
`).all();
return NextResponse.json({ dataDir, dbPath, dbSize, counts, topGroups });
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { setFavorite, tagGroup, untagGroup, tagsForChatroom } from '@/lib/groups';
export const dynamic = 'force-dynamic';
const TagSchema = z.object({
chatroom_id: z.string().min(1),
group_id: z.number().int().positive(),
action: z.enum(['add', 'remove']),
});
const FavSchema = z.object({
chatroom_id: z.string().min(1),
fav: z.boolean(),
});
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const id = url.searchParams.get('chatroom_id');
if (!id) return NextResponse.json({ ok: false, error: 'chatroom_id required' }, { status: 400 });
return NextResponse.json({ ok: true, group_ids: tagsForChatroom(id) });
}
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null);
const tag = TagSchema.safeParse(body);
if (tag.success) {
if (tag.data.action === 'add') tagGroup(tag.data.chatroom_id, tag.data.group_id);
else untagGroup(tag.data.chatroom_id, tag.data.group_id);
return NextResponse.json({ ok: true });
}
const fav = FavSchema.safeParse(body);
if (fav.success) {
setFavorite(fav.data.chatroom_id, fav.data.fav);
return NextResponse.json({ ok: true });
}
return NextResponse.json({ ok: false, error: 'invalid payload' }, { status: 400 });
}
+82
View File
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server';
import { todayStr } from '@/lib/range';
import { db } from '@/lib/db';
import { listMessagesForDate, getSyncState, listAllSyncedDates } from '@/lib/messages-store';
import { wxSessions } from '@/lib/wx';
export const dynamic = 'force-dynamic';
interface DailyHistoryRow {
date: string;
total: number;
}
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
) {
const { id } = await ctx.params;
const chatroomId = decodeURIComponent(id);
const url = new URL(req.url);
const date = url.searchParams.get('date') ?? todayStr();
const limit = Math.min(Number(url.searchParams.get('limit') ?? 1000), 5000);
// 拉群名(从 wx sessions
let chatName = chatroomId;
try {
const sessions = await wxSessions(500);
const found = sessions.find((s) => s.username === chatroomId);
if (found) chatName = found.chat;
} catch {}
// 当日消息
const messages = listMessagesForDate(chatroomId, date, limit);
// 当日聚合统计
const total = messages.length;
const senderMap = new Map<string, number>();
const typeMap = new Map<string, number>();
const hours = new Array(24).fill(0) as number[];
for (const m of messages) {
senderMap.set(m.sender, (senderMap.get(m.sender) ?? 0) + 1);
typeMap.set(m.type, (typeMap.get(m.type) ?? 0) + 1);
if (m.timestamp) {
const h = new Date(m.timestamp * 1000).getHours();
if (h >= 0 && h < 24) hours[h]++;
}
}
const stats = {
chat: chatName,
total,
by_hour: hours.map((count, hour) => ({ hour, count })),
by_type: Array.from(typeMap.entries())
.map(([type, count]) => ({ type, count }))
.sort((a, b) => b.count - a.count),
top_senders: Array.from(senderMap.entries())
.map(([sender, count]) => ({ sender, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 20),
};
// 历史日柱图
const dailyHistory = db()
.prepare(
'SELECT date, total FROM daily_stats WHERE chatroom_id = ? ORDER BY date ASC',
)
.all(chatroomId) as DailyHistoryRow[];
// 同步状态
const syncState = getSyncState(chatroomId);
const syncedDates = listAllSyncedDates(chatroomId);
return NextResponse.json({
ok: true,
chatroom_id: chatroomId,
date,
stats,
recent: messages,
daily_history: dailyHistory,
sync_state: syncState ?? null,
synced_dates: syncedDates,
});
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { createGroup, deleteGroup, listGroups } from '@/lib/groups';
export const dynamic = 'force-dynamic';
const CreateSchema = z.object({
name: z.string().min(1).max(40),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
emoji: z.string().max(8).optional(),
});
export async function GET() {
return NextResponse.json({ ok: true, groups: listGroups() });
}
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null);
const parsed = CreateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: parsed.error.message }, { status: 400 });
}
try {
const id = createGroup(parsed.data);
return NextResponse.json({ ok: true, id });
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'unknown' },
{ status: 500 },
);
}
}
export async function DELETE(req: NextRequest) {
const url = new URL(req.url);
const id = Number(url.searchParams.get('id'));
if (!id) return NextResponse.json({ ok: false, error: 'id required' }, { status: 400 });
deleteGroup(id);
return NextResponse.json({ ok: true });
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { countMentions, listMentions, markMentionsSeen } from '@/lib/mentions';
import { wxSessions } from '@/lib/wx';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const limit = Math.min(Math.max(Number(url.searchParams.get('limit') ?? 1000), 1), 5000);
const sessions = await wxSessions(500);
const nameByChatroom = new Map<string, string>();
for (const s of sessions) nameByChatroom.set(s.username, s.chat);
const items = listMentions(limit).map((m) => ({
...m,
chat_name: nameByChatroom.get(m.chatroom_id) ?? m.chatroom_id,
}));
return NextResponse.json({ ok: true, total: countMentions(), items });
}
export async function POST(req: NextRequest) {
const body = (await req.json().catch(() => ({}))) as { chatroom_id?: string };
markMentionsSeen(body.chatroom_id);
return NextResponse.json({ ok: true });
}
+59
View File
@@ -0,0 +1,59 @@
import { NextRequest } from 'next/server';
import { wxNewMessages, wxSessions } from '@/lib/wx';
export const dynamic = 'force-dynamic';
export const maxDuration = 600;
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const interval = Math.max(Number(url.searchParams.get('interval') ?? 5000), 2000);
const stream = new ReadableStream({
async start(controller) {
const enc = new TextEncoder();
const send = (obj: unknown) =>
controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
let timer: NodeJS.Timeout | null = null;
let stopped = false;
const tick = async () => {
if (stopped) return;
try {
const [msgs, sessions] = await Promise.all([
wxNewMessages(50).catch(() => []),
wxSessions(500).catch(() => []),
]);
const names = new Map<string, string>();
for (const s of sessions) names.set(s.username, s.chat);
const enriched = msgs.map((m) => ({
...m,
chat_name: names.get(m.username) ?? m.username,
}));
send({ type: 'tick', count: msgs.length, items: enriched, ts: Date.now() });
} catch (e) {
send({ type: 'error', error: e instanceof Error ? e.message : 'unknown' });
}
};
// Send initial heartbeat so the client knows the stream is open
send({ type: 'open', interval });
await tick();
timer = setInterval(tick, interval);
req.signal.addEventListener('abort', () => {
stopped = true;
if (timer) clearInterval(timer);
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
});
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { existsSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
export const dynamic = 'force-dynamic';
export async function POST() {
const dataDir = process.env.WECHAT_RADAR_DATA_DIR ?? join(homedir(), '.wechat-radar');
const dest = join(dataDir, 'radar-recovered.db');
try {
db().pragma('wal_checkpoint(TRUNCATE)');
db().exec(`VACUUM INTO '${dest.replace(/'/g, "''")}'`);
const size = existsSync(dest) ? statSync(dest).size : 0;
return NextResponse.json({ ok: true, dest, size });
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'unknown' },
{ status: 500 },
);
}
}
+89
View File
@@ -0,0 +1,89 @@
import { NextRequest } from 'next/server';
import { wxSessions } from '@/lib/wx';
import { syncFullHistory } from '@/lib/stats-aggregator';
import { normalizeDate, normalizeRangeKey, rangeToWindow, type RangeKey } from '@/lib/range';
import { readConfig } from '@/lib/config';
import { cache, CK } from '@/lib/cache';
export const dynamic = 'force-dynamic';
export const maxDuration = 1800; // 30 min
interface RescanBody {
range?: RangeKey;
anchorDate?: string;
since?: string;
until?: string;
full?: boolean; // 一键全量:1 年
}
export async function POST(req: NextRequest) {
const body = (await req.json().catch(() => ({}))) as RescanBody;
let since: string;
let until: string;
let scope: string;
if (body.full) {
const w = rangeToWindow('year');
since = w.since;
until = w.until;
scope = 'full(365d)';
} else if (body.since && body.until) {
since = body.since;
until = body.until;
scope = `custom(${since}~${until})`;
} else {
const range = normalizeRangeKey(body.range, 'month');
const w = rangeToWindow(range, normalizeDate(body.anchorDate));
since = w.since;
until = w.until;
scope = range;
}
const sessions = await wxSessions(500);
const targets = sessions
.filter((s) => s.is_group)
.map((s) => ({ chatroomId: s.username, display: s.chat }));
const cfg = readConfig();
const concurrency = cfg.rescanConcurrency ?? 6;
const stream = new ReadableStream({
async start(controller) {
const enc = new TextEncoder();
const send = (obj: unknown) =>
controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
send({ type: 'start', scope, since, until, groups: targets.length });
try {
const result = await syncFullHistory({
targets,
since,
until,
concurrency,
onProgress: (p) => send(p),
});
cache.del(CK.sessions());
send({
type: 'finished',
ok: result.ok,
failed: result.failed,
messages: result.messages,
});
} catch (e) {
send({ type: 'error', error: e instanceof Error ? e.message : 'unknown' });
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
});
}
+127
View File
@@ -0,0 +1,127 @@
import { NextResponse } from 'next/server';
import { wxSessions } from '@/lib/wx';
import type { WxSession } from '@/lib/wx-types';
import { cache, CK } from '@/lib/cache';
import { listGroups, listAllTags, listFavorites } from '@/lib/groups';
import { effectiveGroupIds } from '@/lib/group-classifier';
import { db } from '@/lib/db';
import { readConfig } from '@/lib/config';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const sessions = await loadSessionsSafe(500);
const groups = listGroups();
const tags = listAllTags();
const favorites = new Set(listFavorites());
const tagsByChatroom = new Map<string, number[]>();
for (const t of tags) {
const arr = tagsByChatroom.get(t.chatroom_id) ?? [];
arr.push(t.group_id);
tagsByChatroom.set(t.chatroom_id, arr);
}
const groupsList = sessions.filter((s) => s.is_group);
const enriched = groupsList.map((s) => {
const groupIds = effectiveGroupIds(
s.chat,
s.summary,
tagsByChatroom.get(s.username) ?? [],
groups,
);
return {
chatroom_id: s.username,
name: s.chat,
last_msg_type: s.last_msg_type,
last_sender: s.last_sender,
summary: s.summary,
time: s.time,
timestamp: s.timestamp,
unread: s.unread,
is_favorite: favorites.has(s.username),
group_ids: groupIds,
};
});
const memberCounts = new Map<number, number>();
for (const g of enriched) {
for (const groupId of g.group_ids) {
memberCounts.set(groupId, (memberCounts.get(groupId) ?? 0) + 1);
}
}
const categories = groups.map((g) => ({
...g,
member_count: memberCounts.get(g.id) ?? 0,
}));
return NextResponse.json({
ok: true,
total: groupsList.length,
groups: enriched,
categories,
});
} catch (e) {
const message = e instanceof Error ? e.message : 'unknown error';
return NextResponse.json({ ok: false, error: message }, { status: 500 });
}
}
async function loadSessionsSafe(limit: number): Promise<WxSession[]> {
if (readConfig().demoMode) return listLocalSessionsFallback(limit);
const cached = cache.get(CK.sessions()) as WxSession[] | undefined;
try {
const sessions = await wxSessions(limit);
cache.set(CK.sessions(), sessions, 60);
return sessions;
} catch (e) {
if (cached?.length) return cached;
console.warn('wx sessions failed, falling back to local radar.db', e);
return listLocalSessionsFallback(limit);
}
}
function listLocalSessionsFallback(limit: number): WxSession[] {
const rows = db()
.prepare(
`
SELECT m.chatroom_id, m.sender, m.content, m.time, m.timestamp, m.type
FROM messages m
JOIN (
SELECT chatroom_id, MAX(timestamp) AS timestamp
FROM messages
GROUP BY chatroom_id
) latest
ON latest.chatroom_id = m.chatroom_id
AND latest.timestamp = m.timestamp
GROUP BY m.chatroom_id
ORDER BY m.timestamp DESC
LIMIT ?
`,
)
.all(limit) as Array<{
chatroom_id: string;
sender: string;
content: string;
time: string;
timestamp: number;
type: string;
}>;
return rows.map((r) => ({
chat: r.chatroom_id,
chat_type: 'group',
is_group: true,
last_msg_type: r.type,
last_sender: r.sender,
summary: r.content,
time: r.time,
timestamp: r.timestamp,
unread: 0,
username: r.chatroom_id,
}));
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { DATA_DIR, configStatus, writeConfig } from '@/lib/config';
import { seedDemoData } from '@/lib/demo-data';
import { wxAvailable, wxDaemonStatus } from '@/lib/wx';
export const dynamic = 'force-dynamic';
const SetupSchema = z.object({
myNicknames: z.array(z.string()).default([]),
privacyConfirmed: z.boolean(),
demoMode: z.boolean().default(false),
defaultSyncDays: z.number().int().min(1).max(365).default(7),
});
export async function GET() {
const [wxInstalled, daemon] = await Promise.all([wxAvailable(), wxDaemonStatus()]);
return NextResponse.json({
ok: true,
...configStatus(),
dataDir: DATA_DIR,
checks: {
wxInstalled,
wxDaemonRunning: daemon.running,
wxDaemonPid: daemon.pid ?? null,
},
});
}
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null);
const parsed = SetupSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: parsed.error.message }, { status: 400 });
}
const names = parsed.data.myNicknames.map((name) => name.trim()).filter(Boolean);
if (!parsed.data.demoMode && names.length === 0) {
return NextResponse.json({ ok: false, error: '请至少填写一个自己的微信名或群昵称' }, { status: 400 });
}
const config = writeConfig({
myNicknames: names,
privacyConfirmed: parsed.data.privacyConfirmed,
demoMode: parsed.data.demoMode,
defaultSyncDays: parsed.data.defaultSyncDays,
setupCompleted: true,
});
const demo = parsed.data.demoMode ? seedDemoData() : null;
return NextResponse.json({ ok: true, configured: true, config, demo });
}
+225
View File
@@ -0,0 +1,225 @@
import { NextRequest, NextResponse } from 'next/server';
import { wxSessions } from '@/lib/wx';
import type { WxSession } from '@/lib/wx-types';
import { listCachedStatsRange } from '@/lib/stats-aggregator';
import { listAllTags, listGroups, listFavorites } from '@/lib/groups';
import { effectiveGroupIds } from '@/lib/group-classifier';
import { rangeToWindow, dateList, normalizeDate, normalizeRangeKey } from '@/lib/range';
import { countMentionsBetween } from '@/lib/mentions';
import { buildDashboardIntelligence } from '@/lib/dashboard-intelligence';
import { cache, CK } from '@/lib/cache';
import { db } from '@/lib/db';
import { readConfig } from '@/lib/config';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
try {
const url = new URL(req.url);
const range = normalizeRangeKey(url.searchParams.get('range'), 'week');
const anchorDate = normalizeDate(url.searchParams.get('date'));
const w = rangeToWindow(range, anchorDate);
const sessions = await loadSessionsSafe(500);
const groups = sessions.filter((s) => s.is_group);
const groupNames = new Map(groups.map((g) => [g.username, g.chat]));
const allCount = groups.length;
const cached = listCachedStatsRange(w.since, w.until);
const totalMessages = cached.reduce((sum, r) => sum + r.total, 0);
const dates = dateList(w.since, w.until);
const trendByDate = new Map<string, number>(dates.map((d) => [d, 0]));
for (const r of cached) {
if (trendByDate.has(r.date)) {
trendByDate.set(r.date, (trendByDate.get(r.date) ?? 0) + r.total);
}
}
const trend = dates.map((d) => ({ date: d, count: trendByDate.get(d) ?? 0 }));
const peak = trend.reduce((max, t) => (t.count > max.count ? t : max), { date: '', count: 0 });
const sumTrend = trend.reduce((s, t) => s + t.count, 0);
const avg = trend.length > 0 ? sumTrend / trend.length : 0;
const totalsByGroup = new Map<string, number>();
const sendersByGroup = new Map<string, Map<string, number>>();
for (const r of cached) {
totalsByGroup.set(r.chatroom_id, (totalsByGroup.get(r.chatroom_id) ?? 0) + r.total);
const senderMap = sendersByGroup.get(r.chatroom_id) ?? new Map<string, number>();
for (const s of r.top_senders) {
senderMap.set(s.sender, (senderMap.get(s.sender) ?? 0) + s.count);
}
sendersByGroup.set(r.chatroom_id, senderMap);
}
const active = groups.filter((g) => (totalsByGroup.get(g.username) ?? 0) > 0).length;
const silent = allCount - active;
const topActiveGroups = groups
.map((g) => ({
chatroom_id: g.username,
name: g.chat,
summary: g.summary,
total: totalsByGroup.get(g.username) ?? 0,
top_senders: Array.from(sendersByGroup.get(g.username)?.entries() ?? [])
.map(([sender, count]) => ({ sender, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 3),
}))
.filter((g) => g.total > 0)
.sort((a, b) => b.total - a.total);
const tags = listAllTags();
const cats = listGroups();
const tagsByChatroom = new Map<string, number[]>();
for (const t of tags) {
const arr = tagsByChatroom.get(t.chatroom_id) ?? [];
arr.push(t.group_id);
tagsByChatroom.set(t.chatroom_id, arr);
}
const effectiveTagsByChatroom = new Map<string, number[]>();
for (const g of groups) {
effectiveTagsByChatroom.set(
g.username,
effectiveGroupIds(g.chat, g.summary, tagsByChatroom.get(g.username) ?? [], cats),
);
}
const taggedChatroomIds = new Set(
Array.from(effectiveTagsByChatroom.entries())
.filter(([, ids]) => ids.length > 0)
.map(([chatroomId]) => chatroomId),
);
const unsortedCount = groups.filter((g) => (effectiveTagsByChatroom.get(g.username) ?? []).length === 0).length;
const categoryStats = cats.map((c) => {
const memberIds = Array.from(effectiveTagsByChatroom.entries())
.filter(([, ids]) => ids.includes(c.id))
.map(([chatroomId]) => chatroomId);
const memberSet = new Set(memberIds);
let groupMessageCount = 0;
for (const r of cached) {
if (memberSet.has(r.chatroom_id)) groupMessageCount += r.total;
}
return {
id: c.id,
name: c.name,
color: c.color,
emoji: c.emoji,
group_count: memberIds.length,
message_count: groupMessageCount,
};
});
const unsortedMessageCount = cached
.filter((r) => !taggedChatroomIds.has(r.chatroom_id))
.reduce((s, r) => s + r.total, 0);
if (unsortedCount > 0) {
categoryStats.push({
id: -1,
name: '未分类',
color: '#94a3b8',
emoji: '❓',
group_count: unsortedCount,
message_count: unsortedMessageCount,
});
}
const favorites = listFavorites();
const mentionCount = countMentionsBetween(unixStartOfDay(w.since), unixEndOfDay(w.until));
return NextResponse.json({
ok: true,
range,
window: w,
cards: {
active_groups: active,
total_groups: allCount,
total_messages: totalMessages,
mentions: mentionCount,
silent_groups: silent,
avg_per_group: allCount ? Math.round(totalMessages / allCount) : 0,
},
trend: {
data: trend,
peak,
avg,
total: sumTrend,
},
active_groups: topActiveGroups,
categories: categoryStats,
intelligence: buildDashboardIntelligence(w.until, groupNames),
sidebar_counts: {
all: allCount,
favorites: favorites.length,
unsorted: unsortedCount,
},
});
} catch (e) {
const message = e instanceof Error ? e.message : 'unknown error';
console.error('/api/stats failed', e);
return NextResponse.json({ ok: false, error: message }, { status: 500 });
}
}
async function loadSessionsSafe(limit: number): Promise<WxSession[]> {
if (readConfig().demoMode) return listLocalSessionsFallback(limit);
const cached = cache.get(CK.sessions()) as WxSession[] | undefined;
try {
const sessions = await wxSessions(limit);
cache.set(CK.sessions(), sessions, 60);
return sessions;
} catch (e) {
if (cached?.length) return cached;
console.warn('wx sessions failed, falling back to local radar.db', e);
return listLocalSessionsFallback(limit);
}
}
function listLocalSessionsFallback(limit: number): WxSession[] {
const rows = db()
.prepare(
`
SELECT m.chatroom_id, m.sender, m.content, m.time, m.timestamp, m.type
FROM messages m
JOIN (
SELECT chatroom_id, MAX(timestamp) AS timestamp
FROM messages
GROUP BY chatroom_id
) latest
ON latest.chatroom_id = m.chatroom_id
AND latest.timestamp = m.timestamp
GROUP BY m.chatroom_id
ORDER BY m.timestamp DESC
LIMIT ?
`,
)
.all(limit) as Array<{
chatroom_id: string;
sender: string;
content: string;
time: string;
timestamp: number;
type: string;
}>;
return rows.map((r) => ({
chat: r.chatroom_id,
chat_type: 'group',
is_group: true,
last_msg_type: r.type,
last_sender: r.sender,
summary: r.content,
time: r.time,
timestamp: r.timestamp,
unread: 0,
username: r.chatroom_id,
}));
}
function unixStartOfDay(date: string) {
const [year, month, day] = date.split('-').map(Number);
return Math.floor(new Date(year, month - 1, day, 0, 0, 0, 0).getTime() / 1000);
}
function unixEndOfDay(date: string) {
const [year, month, day] = date.split('-').map(Number);
return Math.floor(new Date(year, month - 1, day, 23, 59, 59, 999).getTime() / 1000);
}
+18
View File
@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { getTopicDetail } from '@/lib/topics';
export const dynamic = 'force-dynamic';
export async function GET(
_req: NextRequest,
ctx: { params: Promise<{ id: string }> },
) {
const { id } = await ctx.params;
const numId = Number(id);
if (!Number.isInteger(numId) || numId <= 0) {
return NextResponse.json({ ok: false, error: 'invalid id' }, { status: 400 });
}
const detail = await getTopicDetail(numId);
if (!detail) return NextResponse.json({ ok: false, error: 'not found' }, { status: 404 });
return NextResponse.json({ ok: true, ...detail });
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDailyLinkIntelligence } from '@/lib/link-intelligence';
import { todayStr } from '@/lib/range';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const date = url.searchParams.get('date') ?? todayStr();
if (!DATE_RE.test(date)) {
return NextResponse.json({ ok: false, error: 'invalid date' }, { status: 400 });
}
const refresh = url.searchParams.get('refresh') === '1' || url.searchParams.get('refresh') === 'true';
try {
const result = await getDailyLinkIntelligence(date, { refresh });
return NextResponse.json({ ok: true, ...result });
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'unknown' },
{ status: 500 },
);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { listTopics } from '@/lib/topics';
import { todayStr } from '@/lib/range';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const date = url.searchParams.get('date') ?? todayStr();
const topics = listTopics(date);
return NextResponse.json({ ok: true, date, topics });
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest } from 'next/server';
import { readFile } from 'node:fs/promises';
import { mimeFor, resolveWxImage } from '@/lib/wx-image';
import { db } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const localIdStr = url.searchParams.get('local_id');
const chatroomId = url.searchParams.get('chatroom') ?? undefined;
const hintMonth = url.searchParams.get('month') ?? undefined;
const localId = Number(localIdStr);
if (!localIdStr || !Number.isInteger(localId) || localId <= 0) {
return new Response('invalid local_id', { status: 400 });
}
// 自动推断 month:从本地 messages 表查这条消息的日期
let resolvedMonth = hintMonth;
if (!resolvedMonth && chatroomId) {
const row = db()
.prepare('SELECT date FROM messages WHERE chatroom_id = ? AND local_id = ?')
.get(chatroomId, localId) as { date: string } | undefined;
if (row?.date) resolvedMonth = row.date.slice(0, 7);
}
const found = await resolveWxImage(localId, resolvedMonth);
if (!found) {
return new Response('image not found in wx cache', { status: 404 });
}
const buf = await readFile(/*turbopackIgnore: true*/ found.path);
return new Response(new Uint8Array(buf), {
headers: {
'Content-Type': mimeFor(found.format),
'Cache-Control': 'public, max-age=86400, immutable',
'X-Image-Type': found.type,
},
});
}
+158
View File
@@ -0,0 +1,158 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import Sidebar from '@/components/Sidebar';
import { ArrowLeft, Sparkles, Check } from 'lucide-react';
type Group = { id: number; name: string; color: string; emoji: string | null };
type Suggestion = {
chatroom_id: string;
name: string;
summary: string;
suggested_group_id: number | null;
suggested_group_name: string | null;
reason: string;
};
export default function ClassifyPage() {
const [groups, setGroups] = useState<Group[]>([]);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [picks, setPicks] = useState<Record<string, number | null>>({});
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const load = useCallback(async () => {
const r = await fetch('/api/ai-classify');
const j = await r.json();
if (j.ok) {
setGroups(j.groups);
setSuggestions(j.suggestions);
const initial: Record<string, number | null> = {};
for (const s of j.suggestions as Suggestion[]) {
initial[s.chatroom_id] = s.suggested_group_id;
}
setPicks(initial);
}
}, []);
useEffect(() => {
queueMicrotask(() => void load());
}, [load]);
const apply = async () => {
setBusy(true);
setMsg(null);
const list = Object.entries(picks)
.filter(([, v]) => v !== null)
.map(([chatroom_id, group_id]) => ({ chatroom_id, group_id: group_id as number }));
if (list.length === 0) {
setMsg('没有可应用的分类');
setBusy(false);
return;
}
const r = await fetch('/api/ai-classify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ picks: list }),
});
const j = await r.json();
setBusy(false);
if (j.ok) {
setMsg(`已应用 ${j.applied}`);
load();
} else {
setMsg('应用失败:' + (j.error ?? '未知'));
}
};
const matched = suggestions.filter((s) => picks[s.chatroom_id] !== null).length;
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">
<Link href="/" className="text-[var(--text-3)] hover:text-[var(--text)]">
<ArrowLeft size={16} />
</Link>
<div>
<div className="report-kicker">AI Classification</div>
<div className="flex items-center gap-2 text-[15px] font-semibold">
<Sparkles size={16} className="text-[var(--accent)]" />
AI
</div>
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
{suggestions.length} · {matched}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{msg && <span className="text-[12px] text-[var(--text-2)]">{msg}</span>}
<button className="btn btn-primary" onClick={apply} disabled={busy || matched === 0}>
<Check size={13} />
<span>{busy ? '应用中…' : `应用 ${matched}`}</span>
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{suggestions.length === 0 ? (
<div className="py-20 text-center text-[12px] text-[var(--text-3)]">
</div>
) : (
<div className="card overflow-hidden">
<table className="w-full text-[13px]">
<thead className="border-b border-[var(--border-soft)] text-[11px] uppercase tracking-wider text-[var(--text-3)]">
<tr>
<th className="px-4 py-2 text-left font-normal"></th>
<th className="px-4 py-2 text-left font-normal"></th>
<th className="px-4 py-2 text-left font-normal"></th>
<th className="px-4 py-2 text-left font-normal"></th>
</tr>
</thead>
<tbody>
{suggestions.map((s) => (
<tr
key={s.chatroom_id}
className="border-b border-[var(--border-soft)] last:border-b-0 hover:bg-[var(--surface-2)]"
>
<td className="px-4 py-2 max-w-[200px]">
<div className="truncate text-[var(--text)]">{s.name}</div>
</td>
<td className="px-4 py-2 max-w-[260px]">
<div className="truncate text-[11px] text-[var(--text-3)]">{s.summary}</div>
</td>
<td className="px-4 py-2">
<select
value={picks[s.chatroom_id] ?? ''}
onChange={(e) =>
setPicks((p) => ({
...p,
[s.chatroom_id]: e.target.value ? Number(e.target.value) : null,
}))
}
className="control-surface rounded px-2 py-1 text-[12px] text-[var(--text)] outline-none"
>
<option value=""> </option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.emoji ?? ''} {g.name}
</option>
))}
</select>
</td>
<td className="px-4 py-2 text-[11px] text-[var(--text-3)]">{s.reason}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</main>
</div>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+146
View File
@@ -0,0 +1,146 @@
@import "tailwindcss";
:root {
--bg: #0a0d0b;
--bg-2: #10110e;
--surface: #141713;
--surface-2: #1a1e19;
--surface-3: #24281f;
--border: #33382f;
--border-soft: rgba(184, 176, 145, 0.16);
--text: #edf1e8;
--text-2: #b0b3a8;
--text-3: #7d8177;
--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 50px rgba(0, 0, 0, 0.28);
}
@theme inline {
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-surface-2: var(--surface-2);
--color-surface-3: var(--surface-3);
--color-border: var(--border);
--color-text: var(--text);
--color-text-2: var(--text-2);
--color-text-3: var(--text-3);
--color-accent: var(--accent);
--color-accent-2: var(--accent-2);
--color-warn: var(--warn);
--color-danger: var(--danger);
--font-sans: ui-sans-serif, system-ui, -apple-system, "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
font-feature-settings: "tnum" 1;
}
body {
background:
linear-gradient(120deg, rgba(125, 211, 168, 0.055), transparent 34%),
linear-gradient(180deg, rgba(213, 162, 83, 0.075), transparent 30%),
var(--bg);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #26362d;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #355044;
}
.card {
background:
linear-gradient(180deg, rgba(237, 241, 232, 0.035), transparent 48%),
var(--surface);
border: 1px solid var(--border-soft);
border-radius: 8px;
box-shadow: var(--shadow);
}
.btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid var(--border-soft);
background: rgba(16, 24, 18, 0.86);
color: var(--text-2);
font-size: 13px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s, opacity 0.15s;
}
.btn:hover {
border-color: rgba(125, 211, 168, 0.38);
background: var(--surface-2);
color: var(--text);
}
.btn:disabled {
cursor: wait;
opacity: 0.62;
}
.btn-active {
background: var(--accent-soft);
color: var(--accent);
border-color: rgba(125, 211, 168, 0.36);
}
.btn-warn {
background: var(--warn-soft);
color: var(--warn);
border-color: rgba(213, 162, 83, 0.38);
}
.btn-primary {
background: linear-gradient(180deg, var(--accent), var(--accent-2));
color: #07120c;
border-color: var(--accent);
font-weight: 600;
}
.btn-primary:hover {
background: linear-gradient(180deg, #98e2bb, var(--accent-2));
color: #07120c;
}
.control-surface {
border: 1px solid var(--border-soft);
background: rgba(16, 24, 18, 0.82);
box-shadow: 0 1px 0 rgba(237, 241, 232, 0.03) inset;
}
.report-kicker {
color: var(--accent);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.signal-chip {
border: 1px solid rgba(125, 211, 168, 0.2);
background: var(--accent-soft);
color: var(--accent);
}
+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>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "微信雷达",
description: "本地优先的微信群聊情报看板",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" className="h-full">
<body className="min-h-full">{children}</body>
</html>
);
}
+216
View File
@@ -0,0 +1,216 @@
'use client';
import { useEffect, useState, type ReactNode } from 'react';
import Link from 'next/link';
import Sidebar from '@/components/Sidebar';
import { Calendar, ExternalLink, Newspaper, RefreshCw, Wrench } from 'lucide-react';
type LinkInsight = {
kind: 'article' | 'tool';
url: string;
canonical_url: string;
title: string;
domain: string;
count: number;
group_count: number;
first_seen: string;
last_seen: string;
sources: Array<{
chatroom_id: string;
chat_name: string;
sender: string;
time: string;
local_id: number;
snippet: string;
}>;
};
type LinkInsightResp = {
ok: boolean;
date: string;
articles: LinkInsight[];
tools: LinkInsight[];
};
function localToday(): string {
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}`;
}
export default function LinksPage() {
const [date, setDate] = useState(() => localToday());
const [links, setLinks] = useState<LinkInsightResp | null>(null);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await fetch(`/api/topics/links?date=${date}`);
const j = (await r.json()) as LinkInsightResp;
if (!cancelled && j.ok) setLinks(j);
} catch {}
})();
return () => {
cancelled = true;
};
}, [date]);
const loading = links?.date !== date;
async function refreshLinks() {
setRefreshing(true);
try {
const r = await fetch(`/api/topics/links?date=${date}&refresh=1`, { cache: 'no-store' });
const j = (await r.json()) as LinkInsightResp;
if (j.ok) setLinks(j);
} finally {
setRefreshing(false);
}
}
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">Link Intelligence</div>
<div className="flex items-center gap-2 text-[15px] font-semibold">
<Newspaper size={16} className="text-[var(--accent)]" />
·
</div>
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
{loading
? `${date} · 加载中…`
: `${date} · ${links.articles.length} 篇文章 · ${links.tools.length} 个工具/资源`}
</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) => setDate(e.target.value)}
className="bg-transparent text-[12px] outline-none [color-scheme:dark]"
/>
</div>
<button
type="button"
onClick={refreshLinks}
disabled={refreshing}
className="btn"
title="重新整理当天链接标题和去重结果"
>
<RefreshCw size={13} className={refreshing ? 'animate-spin' : ''} />
{refreshing ? '整理中' : '重新整理'}
</button>
</div>
</div>
<div className="grid flex-1 grid-cols-1 gap-5 overflow-hidden p-5 xl:grid-cols-2">
<LinkInsightPanel
title="文章链接"
icon={<Newspaper size={14} className="text-[var(--accent)]" />}
items={loading ? [] : links.articles}
date={date}
loading={loading}
empty="当天还没有文章链接"
/>
<LinkInsightPanel
title="工具与资源"
icon={<Wrench size={14} className="text-[var(--warn)]" />}
items={loading ? [] : links.tools}
date={date}
loading={loading}
empty="当天还没有工具链接"
/>
</div>
</main>
</div>
);
}
function LinkInsightPanel({
title,
icon,
items,
date,
loading,
empty,
}: {
title: string;
icon: ReactNode;
items: LinkInsight[];
date: string;
loading: boolean;
empty: string;
}) {
return (
<section className="card flex min-h-0 min-w-0 flex-col">
<div className="flex items-center justify-between border-b border-[var(--border-soft)] px-3 py-2">
<div className="flex items-center gap-2 text-[12px] font-semibold">
{icon}
<span>{title}</span>
</div>
<div className="text-[10px] text-[var(--text-3)]">{loading ? '加载中' : `${items.length}`}</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{loading ? (
<div className="py-16 text-center text-[11px] text-[var(--text-3)]"></div>
) : items.length === 0 ? (
<div className="py-16 text-center text-[11px] text-[var(--text-3)]">{empty}</div>
) : (
<div className="space-y-1.5">
{items.map((item) => (
<LinkInsightRow key={item.canonical_url} item={item} date={date} />
))}
</div>
)}
</div>
</section>
);
}
function LinkInsightRow({ item, date }: { item: LinkInsight; date: string }) {
const first = item.sources[0];
return (
<div className="rounded-md border border-transparent px-2 py-2 hover:border-[var(--border-soft)] hover:bg-[var(--surface-2)]">
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="group flex min-w-0 items-start justify-between gap-2"
title={item.title}
>
<span className="min-w-0">
<span className="line-clamp-2 text-[12px] font-medium leading-snug text-[var(--text)] group-hover:text-[var(--accent)]">
{item.title}
</span>
<span className="mt-1 block truncate text-[10px] text-[var(--text-3)]">{item.domain}</span>
</span>
<ExternalLink size={12} className="mt-0.5 shrink-0 text-[var(--text-3)] group-hover:text-[var(--accent)]" />
</a>
<div className="mt-1 flex items-center justify-between gap-2 text-[10px] text-[var(--text-3)]">
<Link
href={`/groups/${encodeURIComponent(first.chatroom_id)}?date=${date}`}
className="min-w-0 truncate text-[var(--text-2)] hover:text-[var(--accent)]"
title={`${first.chat_name} · ${first.sender}`}
>
{first.chat_name} · {first.sender}
</Link>
<span className="shrink-0 tabular-nums">
{item.count > 1 ? `${item.count} 次 · ` : ''}
{item.last_seen?.slice(11) ?? ''}
</span>
</div>
{first.snippet && (
<div className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">{first.snippet}</div>
)}
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import Sidebar from '@/components/Sidebar';
import MessageContent from '@/components/MessageContent';
import { AtSign, ChevronRight, Search } from 'lucide-react';
type MentionItem = {
chatroom_id: string;
chat_name: string;
local_id: number;
sender: string;
content: string;
time: string;
timestamp: number;
seen: number;
};
type MentionsResp = {
ok: boolean;
total: number;
items: MentionItem[];
};
function dateOf(time: string) {
return time?.slice(0, 10) || '';
}
export default function MentionsPage() {
const [data, setData] = useState<MentionsResp | null>(null);
const [q, setQ] = useState('');
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await fetch('/api/mentions?limit=5000');
const j = (await r.json()) as MentionsResp;
if (!cancelled && j.ok) setData(j);
} catch {}
})();
return () => {
cancelled = true;
};
}, []);
const filtered = useMemo(() => {
const items = data?.items ?? [];
const keyword = q.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) => {
const haystack = `${item.chat_name} ${item.sender} ${item.content}`.toLowerCase();
return haystack.includes(keyword);
});
}, [data, q]);
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)] px-6 py-3">
<div>
<div className="flex items-center gap-2 text-[15px] font-semibold">
<AtSign size={16} className="text-[var(--warn)]" />
@
</div>
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
{data ? `${filtered.length} / ${data.total}` : '加载中…'}
</div>
</div>
<div className="flex items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--surface)] 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-72 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-2">
{filtered.map((item) => {
const date = dateOf(item.time);
return (
<div key={`${item.chatroom_id}-${item.local_id}`} className="card p-3 text-[12px]">
<div className="flex items-start justify-between gap-3 text-[11px] text-[var(--text-3)]">
<div className="min-w-0">
<Link
href={`/groups/${encodeURIComponent(item.chatroom_id)}${date ? `?date=${date}` : ''}`}
className="text-[var(--accent)] hover:underline"
>
{item.chat_name}
</Link>
<span>{' · '}</span>
<span className="font-medium text-[var(--text-2)]">{item.sender || '未知发送人'}</span>
</div>
<div className="shrink-0 text-right tabular-nums">
<div>{item.time || '未知时间'}</div>
</div>
</div>
<div className="mt-2 leading-relaxed text-[var(--text)]">
<MessageContent content={item.content} chatroomId={item.chatroom_id} />
</div>
<div className="mt-2 flex justify-end">
<Link
href={`/groups/${encodeURIComponent(item.chatroom_id)}${date ? `?date=${date}` : ''}`}
className="inline-flex items-center gap-1 text-[11px] text-[var(--text-3)] hover:text-[var(--text)]"
>
<span></span>
<ChevronRight size={13} />
</Link>
</div>
</div>
);
})}
</div>
)}
</div>
</main>
</div>
);
}
+202
View File
@@ -0,0 +1,202 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Sidebar from '@/components/Sidebar';
import TopBar, { type RangeKey, type RefreshMode } from '@/components/TopBar';
import StatGrid, { type CardsData } from '@/components/StatGrid';
import TrendChart, { type TrendPoint } from '@/components/TrendChart';
import ActiveGroupsList, { type ActiveGroup } from '@/components/ActiveGroupsList';
import CategoryChart, { type CategoryStat } from '@/components/CategoryChart';
import IntelligenceBrief, { type DashboardIntelligence } from '@/components/IntelligenceBrief';
type StatsResponse = {
ok: boolean;
error?: string;
range: RangeKey;
window: { since: string; until: string; days: number };
cards: CardsData;
trend: { data: TrendPoint[]; peak: TrendPoint; avg: number; total: number };
active_groups: ActiveGroup[];
categories: CategoryStat[];
intelligence: DashboardIntelligence;
};
export default function Page() {
const [range, setRange] = useState<RangeKey>('month');
const [date, setDate] = useState(() => localToday());
const [mode, setMode] = useState<RefreshMode>('auto');
const [stats, setStats] = useState<StatsResponse | null>(null);
const [rescanning, setRescanning] = useState(false);
const [rescanInfo, setRescanInfo] = useState<string | undefined>(undefined);
const [setupChecked, setSetupChecked] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await fetch('/api/setup', { cache: 'no-store' });
const j = await r.json();
if (!cancelled && j.ok && !j.configured) {
window.location.href = '/setup';
return;
}
} catch {}
if (!cancelled) setSetupChecked(true);
})();
return () => {
cancelled = true;
};
}, []);
const reload = useCallback(async () => {
try {
setStats(await fetchStats(range, date));
} catch (e) {
console.error(e);
}
}, [range, date]);
useEffect(() => {
if (!setupChecked) return;
let cancelled = false;
(async () => {
try {
const j = await fetchStats(range, date);
if (!cancelled) setStats(j);
} catch (e) {
console.error(e);
}
})();
return () => {
cancelled = true;
};
}, [range, date, setupChecked]);
const runRescan = useCallback(
async (full: boolean) => {
setRescanning(true);
setRescanInfo(full ? '全量同步启动…(365 天,预计 8-15 分钟)' : '启动重扫…');
try {
const r = await fetch('/api/rescan', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(full ? { full: true } : { range, anchorDate: date }),
});
if (!r.ok || !r.body) {
setRescanInfo('重扫失败');
setRescanning(false);
return;
}
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf('\n\n')) !== -1) {
const chunk = buf.slice(0, nl).trim();
buf = buf.slice(nl + 2);
if (!chunk.startsWith('data:')) continue;
try {
const evt = JSON.parse(chunk.slice(5).trim());
if (evt.type === 'start') {
setRescanInfo(`同步 ${evt.groups} 群 · ${evt.since} ~ ${evt.until}`);
} else if (evt.type === 'progress') {
const pct = Math.floor((evt.done / evt.total) * 100);
setRescanInfo(
`同步中 ${evt.done}/${evt.total} (${pct}%) · 已存 ${evt.inserted_messages ?? 0} 条 · ${evt.current ?? ''}`,
);
} else if (evt.type === 'done' || evt.type === 'finished') {
setRescanInfo(
`完成 · ${evt.messages ?? evt.inserted_messages ?? 0} 条消息已入库`,
);
}
} catch {}
}
}
} catch (e) {
setRescanInfo('重扫失败:' + (e instanceof Error ? e.message : 'unknown'));
} finally {
setRescanning(false);
reload();
}
},
[range, date, reload],
);
if (!setupChecked) {
return <div className="flex h-screen items-center justify-center bg-[var(--bg)] text-[12px] text-[var(--text-3)]"></div>;
}
return (
<div className="flex h-screen bg-[var(--bg)]">
<Sidebar />
<main className="flex flex-1 flex-col overflow-hidden">
<TopBar
range={range}
date={date}
onRangeChange={setRange}
onDateChange={setDate}
mode={mode}
onModeChange={setMode}
rescanning={rescanning}
onRescan={() => runRescan(false)}
onFullSync={() => runRescan(true)}
rescanInfo={rescanInfo ?? infoLine(stats)}
/>
<div className="flex-1 overflow-y-auto px-6 py-5">
<StatGrid cards={stats?.cards} days={stats?.window.days ?? 7} />
<div className="mt-4">
<IntelligenceBrief intelligence={stats?.intelligence} />
</div>
<div className="mt-4">
<TrendChart
data={stats?.trend.data ?? []}
peak={stats?.trend.peak ?? { date: '', count: 0 }}
avg={stats?.trend.avg ?? 0}
total={stats?.trend.total ?? 0}
/>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 2xl:grid-cols-[1.4fr_1fr]">
<ActiveGroupsList groups={stats?.active_groups ?? []} />
<CategoryChart categories={stats?.categories ?? []} />
</div>
</div>
</main>
</div>
);
}
function localToday(): string {
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}`;
}
function infoLine(stats: StatsResponse | null) {
if (!stats) return undefined;
return `${stats.window.since} ~ ${stats.window.until} · 共 ${stats.cards.total_groups} 个群`;
}
async function fetchStats(range: RangeKey, date: string): Promise<StatsResponse> {
const r = await fetch(`/api/stats?range=${range}&date=${date}`, { cache: 'no-store' });
const text = await r.text();
if (!text.trim()) {
throw new Error(`/api/stats returned an empty response (${r.status})`);
}
const j = JSON.parse(text) as StatsResponse;
if (!r.ok || !j.ok) {
throw new Error(j.error ?? `/api/stats failed (${r.status})`);
}
return j;
}
+143
View File
@@ -0,0 +1,143 @@
'use client';
import { useEffect, useState } from 'react';
import { CheckCircle2, Database, ShieldCheck, UserRound, Wrench } from 'lucide-react';
type SetupStatus = {
ok: boolean;
dataDir: string;
configured: boolean;
config: { myNicknames: string[]; demoMode: boolean; privacyConfirmed: boolean; defaultSyncDays: number };
checks: { wxInstalled: boolean; wxDaemonRunning: boolean; wxDaemonPid: number | null };
};
export default function SetupPage() {
const [status, setStatus] = useState<SetupStatus | null>(null);
const [names, setNames] = useState('');
const [demoMode, setDemoMode] = useState(false);
const [privacyConfirmed, setPrivacyConfirmed] = useState(false);
const [defaultSyncDays, setDefaultSyncDays] = useState(7);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
const res = await fetch('/api/setup', { cache: 'no-store' });
const json = (await res.json()) as SetupStatus;
setStatus(json);
setNames(json.config.myNicknames.join(', '));
setDemoMode(json.config.demoMode);
setPrivacyConfirmed(json.config.privacyConfirmed);
setDefaultSyncDays(json.config.defaultSyncDays ?? 7);
})();
}, []);
async function submit() {
setBusy(true);
setError(null);
try {
const res = await fetch('/api/setup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
myNicknames: names.split(',').map((name) => name.trim()).filter(Boolean),
demoMode,
privacyConfirmed,
defaultSyncDays,
}),
});
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(json.error ?? '保存失败');
window.location.href = '/';
} catch (e) {
setError(e instanceof Error ? e.message : '保存失败');
} finally {
setBusy(false);
}
}
return (
<main className="min-h-screen bg-[var(--bg)] px-6 py-8 text-[var(--text)]">
<div className="mx-auto max-w-4xl">
<div className="report-kicker">WeChat Radar Setup</div>
<h1 className="mt-2 text-[28px] font-semibold"></h1>
<p className="mt-2 text-[13px] leading-relaxed text-[var(--text-2)]">
使
</p>
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<section className="card p-5">
<SectionTitle icon={<Wrench size={15} />} title="环境检查" />
<CheckRow label="wx-cli" ok={status?.checks.wxInstalled ?? false} detail={status?.checks.wxInstalled ? '已安装' : '未检测到 wx 命令'} />
<CheckRow label="wx-daemon" ok={status?.checks.wxDaemonRunning ?? false} detail={status?.checks.wxDaemonRunning ? `运行中 PID ${status?.checks.wxDaemonPid ?? ''}` : '未运行,可先使用 demo 模式'} />
<CheckRow label="数据目录" ok detail={status?.dataDir ?? '加载中'} />
</section>
<section className="card p-5">
<SectionTitle icon={<UserRound size={15} />} title="你的微信名" />
<label className="mt-3 block text-[12px] text-[var(--text-3)]"></label>
<input
value={names}
onChange={(e) => setNames(e.target.value)}
placeholder="张三, San Zhang, zhangsan"
className="control-surface mt-2 w-full rounded-md px-3 py-2 text-[13px] outline-none"
/>
<p className="mt-2 text-[11px] text-[var(--text-3)]"> @我的</p>
</section>
<section className="card p-5">
<SectionTitle icon={<Database size={15} />} title="数据模式" />
<label className="mt-4 flex items-center gap-2 text-[13px]">
<input type="checkbox" checked={demoMode} onChange={(e) => setDemoMode(e.target.checked)} />
使
</label>
<label className="mt-4 block text-[12px] text-[var(--text-3)]"></label>
<select
value={defaultSyncDays}
onChange={(e) => setDefaultSyncDays(Number(e.target.value))}
className="control-surface mt-2 rounded-md px-3 py-2 text-[13px] outline-none"
>
<option value={1}> 1 </option>
<option value={7}> 7 </option>
<option value={30}> 30 </option>
<option value={365}> 365 </option>
</select>
</section>
<section className="card p-5">
<SectionTitle icon={<ShieldCheck size={15} />} title="隐私确认" />
<label className="mt-4 flex items-start gap-2 text-[13px] leading-relaxed">
<input className="mt-1" type="checkbox" checked={privacyConfirmed} onChange={(e) => setPrivacyConfirmed(e.target.checked)} />
<span> SQLite </span>
</label>
</section>
</div>
{error && <div className="mt-4 text-[13px] text-[var(--danger)]">{error}</div>}
<div className="mt-6 flex justify-end gap-2">
<button className="btn" onClick={() => window.location.href = '/'}></button>
<button className="btn btn-primary" disabled={busy || !privacyConfirmed} onClick={submit}>
{busy ? '保存中…' : '完成配置'}
</button>
</div>
</div>
</main>
);
}
function SectionTitle({ icon, title }: { icon: React.ReactNode; title: string }) {
return <div className="flex items-center gap-1.5 text-[14px] font-semibold text-[var(--text)]">{icon}{title}</div>;
}
function CheckRow({ label, ok, detail }: { label: string; ok: boolean; detail: string }) {
return (
<div className="mt-3 flex items-center justify-between gap-3 text-[13px]">
<span className="text-[var(--text-2)]">{label}</span>
<span className="flex min-w-0 items-center gap-1.5 text-right text-[12px] text-[var(--text-3)]">
<CheckCircle2 size={13} className={ok ? 'text-[var(--accent)]' : 'text-[var(--text-3)]'} />
<span className="truncate">{detail}</span>
</span>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Sidebar from '@/components/Sidebar';
import { Activity, Pause, Play } from 'lucide-react';
type StreamMessage = {
local_id: number;
username: string;
chat_name: string;
sender: string;
content: string;
time: string;
timestamp: number;
type: string;
};
type StreamEvent =
| { type: 'open'; interval: number }
| { type: 'tick'; count: number; items: StreamMessage[]; ts: number }
| { type: 'error'; error: string };
export default function SignalsPage() {
const [items, setItems] = useState<StreamMessage[]>([]);
const [running, setRunning] = useState(true);
const [lastTick, setLastTick] = useState<number | null>(null);
const [err, setErr] = useState<string | null>(null);
const ctlRef = useRef<AbortController | null>(null);
useEffect(() => {
if (!running) {
ctlRef.current?.abort();
ctlRef.current = null;
return;
}
const ctl = new AbortController();
ctlRef.current = ctl;
(async () => {
try {
const r = await fetch('/api/new-messages', { signal: ctl.signal });
if (!r.body) return;
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf('\n\n')) !== -1) {
const chunk = buf.slice(0, nl).trim();
buf = buf.slice(nl + 2);
if (!chunk.startsWith('data:')) continue;
try {
const evt = JSON.parse(chunk.slice(5).trim()) as StreamEvent;
if (evt.type === 'tick') {
setLastTick(evt.ts);
setErr(null);
if (evt.items.length) {
setItems((prev) => [...evt.items, ...prev].slice(0, 200));
}
} else if (evt.type === 'error') {
setErr(evt.error);
}
} catch {}
}
}
} catch (e) {
if ((e as Error).name !== 'AbortError') setErr((e as Error).message);
}
})();
return () => ctl.abort();
}, [running]);
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">Live Signals</div>
<div className="flex items-center gap-2 text-[15px] font-semibold">
<Activity size={16} className="text-[var(--accent)]" />
·
</div>
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
{err
? `错误:${err}`
: lastTick
? `上次刷新:${new Date(lastTick).toLocaleTimeString()} · ${items.length} 条已收`
: '等待第一条消息…'}
</div>
</div>
<button
className={`btn ${running ? 'btn-warn' : 'btn-primary'}`}
onClick={() => setRunning((v) => !v)}
>
{running ? <Pause size={13} /> : <Play size={13} />}
<span>{running ? '暂停' : '继续'}</span>
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{items.length === 0 ? (
<div className="py-20 text-center text-[12px] text-[var(--text-3)]">
5
</div>
) : (
<div className="space-y-2">
{items.map((m, i) => (
<Row key={`${m.username}-${m.local_id}-${i}`} m={m} />
))}
</div>
)}
</div>
</main>
</div>
);
}
function Row({ m }: { m: StreamMessage }) {
return (
<div className="card grid grid-cols-[140px_1fr_120px] gap-3 px-4 py-3 text-[13px]">
<div className="truncate text-[var(--text-2)]">
<div className="truncate font-medium text-[var(--text)]">{m.chat_name}</div>
<div className="truncate text-[11px] text-[var(--text-3)]">{m.sender}</div>
</div>
<div className="min-w-0">
<div className="truncate text-[var(--text)]">{m.content}</div>
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">{m.type}</div>
</div>
<div className="text-right text-[11px] text-[var(--text-3)] tabular-nums">{m.time}</div>
</div>
);
}
+273
View File
@@ -0,0 +1,273 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import Sidebar from '@/components/Sidebar';
import MessageContent from '@/components/MessageContent';
import { Sparkles, RefreshCw, Calendar } from 'lucide-react';
type Topic = {
id: number;
date: string;
title: string;
summary: string;
message_count: number;
group_count: number;
};
type TopicMessage = {
chatroom_id: string;
chat_name: string;
local_id: number;
sender: string;
content: string;
time: string;
timestamp: number;
type: string;
score: number;
};
function localToday(): string {
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}`;
}
export default function TopicsPage() {
const [date, setDate] = useState(() => localToday());
const [topics, setTopics] = useState<Topic[]>([]);
const [selected, setSelected] = useState<number | null>(null);
const [detail, setDetail] = useState<{ topic: Topic; messages: TopicMessage[] } | null>(null);
const [busy, setBusy] = useState(false);
const [info, setInfo] = useState<string | undefined>(undefined);
const reload = useCallback(async () => {
try {
const r = await fetch(`/api/topics?date=${date}`);
const j = await r.json();
if (j.ok) setTopics(j.topics);
} catch {}
}, [date]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await fetch(`/api/topics?date=${date}`);
const j = await r.json();
if (!cancelled && j.ok) setTopics(j.topics);
} catch {}
})();
return () => {
cancelled = true;
};
}, [date]);
useEffect(() => {
if (!selected) {
return;
}
let cancelled = false;
(async () => {
const r = await fetch(`/api/topics/${selected}`);
const j = await r.json();
if (!cancelled && j.ok) {
setDetail({
topic: {
id: j.id,
date: j.date,
title: j.title,
summary: j.summary,
message_count: j.message_count,
group_count: j.group_count,
},
messages: j.messages,
});
}
})();
return () => {
cancelled = true;
};
}, [selected]);
const selectedDetail = selected ? detail : null;
const build = useCallback(async () => {
setBusy(true);
setInfo('启动 Codex CLI 话题聚合…');
try {
const r = await fetch('/api/topics/build', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ date }),
});
if (!r.ok || !r.body) {
setInfo('构建失败');
setBusy(false);
return;
}
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf('\n\n')) !== -1) {
const chunk = buf.slice(0, nl).trim();
buf = buf.slice(nl + 2);
if (!chunk.startsWith('data:')) continue;
try {
const evt = JSON.parse(chunk.slice(5).trim());
if (evt.type === 'start') {
setInfo(`${date} · 开始构建话题…`);
} else if (evt.type === 'load') {
setInfo(evt.message ?? '加载当日消息…');
} else if (evt.type === 'llm' && evt.done !== undefined) {
setInfo(evt.message ?? `Codex 聚合 ${evt.done}/${evt.total}`);
} else if (evt.type === 'save' && evt.done !== undefined) {
setInfo(`保存话题 ${evt.done}/${evt.total} · ${evt.message ?? ''}`);
} else if (evt.type === 'finished' || evt.type === 'done') {
setInfo(`完成 · ${evt.topics ?? evt.count ?? 0} 个话题`);
} else if (evt.type === 'error') {
setInfo('错误:' + evt.error);
} else if (evt.message) {
setInfo(evt.message);
}
} catch {}
}
}
} catch (e) {
setInfo('错误:' + (e instanceof Error ? e.message : 'unknown'));
} finally {
setBusy(false);
reload();
}
}, [date, 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">Cross-Group Topics</div>
<div className="flex items-center gap-2 text-[15px] font-semibold">
<Sparkles size={16} className="text-[var(--accent)]" />
·
</div>
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
{info ?? `${date} · ${topics.length} 个话题`}
</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) => setDate(e.target.value)}
className="bg-transparent text-[12px] outline-none [color-scheme:dark]"
/>
</div>
<button className={`btn ${busy ? 'btn-warn' : 'btn-primary'}`} onClick={build} disabled={busy}>
<RefreshCw size={13} className={busy ? 'animate-spin' : ''} />
<span>{busy ? '构建中…' : '构建话题'}</span>
</button>
</div>
</div>
<div className="grid flex-1 grid-cols-[420px_1fr] overflow-hidden">
<div className="overflow-y-auto border-r border-[var(--border-soft)] p-4">
{topics.length === 0 ? (
<div className="py-16 text-center text-[12px] text-[var(--text-3)]">
·
</div>
) : (
<div className="space-y-2">
{topics.map((t) => (
<button
key={t.id}
className={`card w-full p-4 text-left transition-colors ${
selected === t.id ? 'border-[rgba(125,211,168,0.48)] bg-[var(--surface-2)]' : 'hover:bg-[var(--surface-2)]'
}`}
onClick={() => {
setDetail(null);
setSelected(t.id);
}}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="text-[14px] font-semibold text-[var(--text)]">{t.title}</div>
{t.summary && (
<div className="mt-1 line-clamp-2 text-[11px] text-[var(--text-3)]">
{t.summary}
</div>
)}
</div>
<div className="text-right text-[10px] text-[var(--text-3)] shrink-0">
<div className="font-semibold text-[var(--accent)]">{t.message_count}</div>
<div>{t.group_count} </div>
</div>
</div>
</button>
))}
</div>
)}
</div>
<div className="overflow-y-auto p-5">
{!selectedDetail ? (
<div className="flex h-full items-center justify-center text-[12px] text-[var(--text-3)]">
</div>
) : (
<div>
<div className="mb-2 text-[18px] font-semibold">{selectedDetail.topic.title}</div>
{selectedDetail.topic.summary && (
<div className="mb-4 text-[13px] leading-relaxed text-[var(--text-2)]">
{selectedDetail.topic.summary}
</div>
)}
<div className="mb-4 flex gap-4 text-[11px] text-[var(--text-3)]">
<span>{selectedDetail.topic.message_count}</span>
<span>{selectedDetail.topic.group_count}</span>
<span>{selectedDetail.topic.date}</span>
</div>
<div className="space-y-2">
{selectedDetail.messages.map((m) => (
<div
key={`${m.chatroom_id}-${m.local_id}`}
className="card p-3 text-[12px]"
>
<div className="flex items-center justify-between text-[11px] text-[var(--text-3)]">
<span>
<Link
href={`/groups/${encodeURIComponent(m.chatroom_id)}?date=${selectedDetail.topic.date}`}
className="text-[var(--accent)] hover:underline"
>
{m.chat_name}
</Link>
{' · '}
<span className="font-medium text-[var(--text-2)]">{m.sender}</span>
</span>
<span className="tabular-nums">{m.time?.slice(11) ?? ''}</span>
</div>
<div className="mt-1.5 text-[var(--text)]">
<MessageContent content={m.content} chatroomId={m.chatroom_id} />
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</main>
</div>
);
}