mirror of
https://github.com/joeseesun/wechat-radar.git
synced 2026-09-08 03:18:31 +09:00
Initial open source release
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+146
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user