import { useCallback, useEffect, useState } from 'react' import { Button, Card, Input, Label, Select, ListBox, Switch, TextField, toast } from '@heroui/react' import { api } from '../../api/client' import { Tabs, thCls, tdCls, selectCls, type TabItem } from '../../components/ui' const gc = api('galgame_card') interface Group { group_id: number; group_name: string; enabled: boolean participants: number; corpus_count: number; impression_count: number; summary_count: number } interface Participant { user_id: number; corpus_count: number } interface LogRow { id: number; user_id: number; nickname?: string; content: string; target_user_id?: number | null; created_at: string } interface ImpressionRow { id: number; user_id: number; content: string; cover_from_id: number; cover_to_id: number; created_at: string } interface SummaryRow { version: number; card_text: string; corpus_count: number; impression_count: number; created_at: string } interface SettingsData { thresholds: Record } function unpack(res: any, errorLabel: string = '操作失败') { if (res && res.status !== 0) throw new Error(res.msg || errorLabel) return res ? res.data : null } function fmtTime(ts: string) { return ts || '—' } type GalTab = 'participants' | 'logs' | 'impressions' | 'summaries' const GAL_TABS: TabItem[] = [ { key: 'participants', label: '参与者' }, { key: 'logs', label: '语料' }, { key: 'impressions', label: '印象' }, { key: 'summaries', label: '画像' }, ] const DATA_TABS: TabItem[] = GAL_TABS.slice(1) export default function GalgameCardPage() { const [view, setView] = useState<'groups' | 'detail' | 'data' | 'settings'>('groups') const [groups, setGroups] = useState([]) const [activeGid, setActiveGid] = useState(null) const [error, setError] = useState('') const [loading, setLoading] = useState(false) const loadGroups = useCallback(async () => { setLoading(true); setError('') try { const data = await unpack(await gc.get('groups'), '加载群列表失败') setGroups(data || []) } catch (e: any) { setError(e.message || '加载失败') } finally { setLoading(false) } }, []) useEffect(() => { loadGroups() }, [loadGroups]) const toggleGroup = async (g: Group, enabled: boolean) => { try { await gc.post(`groups/${g.group_id}/enabled`, { enabled }); await loadGroups() } catch (e: any) { toast.danger(e.message || '切换失败') } } const openDetail = (g: Group) => { setActiveGid(g.group_id); setView('detail') } return (

群聊人设卡

群采集开关 · 参与者 · 语料/印象/画像浏览

{(['groups', 'data', 'settings'] as const).map(v => ( ))}
{error &&

{error}

} {view === 'groups' && ( 群采集开关
{groups.map(g => ( ))} {!groups.length && }
群开关参与者语料印象画像
{g.group_name || '(未命名群)'}
{g.group_id}
toggleGroup(g, v)}> {g.participants}{g.corpus_count}{g.impression_count}{g.summary_count}
暂无管理中的群,请先在「添加群」中添加(或等待数据采集自动登记)
)} {view === 'detail' && activeGid !== null && setView('groups')} />} {view === 'data' && } {view === 'settings' && }
) } function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) { const [tab, setTab] = useState('participants') const [participants, setParticipants] = useState([]) const [logs, setLogs] = useState([]) const [logTotal, setLogTotal] = useState(0) const [logPage, setLogPage] = useState(1) const [impressions, setImpressions] = useState([]) const [summaries, setSummaries] = useState([]) const [addUserId, setAddUserId] = useState('') const [logFilter, setLogFilter] = useState('') const loadParticipants = useCallback(async () => { try { const d = await unpack(await gc.get(`groups/${gid}/participants`), '加载参与者失败'); setParticipants(d || []) } catch (e: any) { toast.danger(e.message) } }, [gid]) const loadLogs = useCallback(async (page: number, userId = '') => { const q = new URLSearchParams({ page: String(page), per_page: '50' }) if (userId) q.set('user_id', userId) try { const d = await unpack(await gc.get(`groups/${gid}/logs?${q}`), '加载语料失败') setLogs((d && d.items) || []); setLogTotal((d && d.total) || 0); setLogPage(page) } catch (e: any) { toast.danger(e.message) } }, [gid]) const loadImpressions = useCallback(async () => { try { const d = await unpack(await gc.get(`groups/${gid}/impressions?page=1&per_page=100`), '加载印象失败'); setImpressions((d && d.items) || []) } catch (e: any) { toast.danger(e.message) } }, [gid]) const loadSummaries = useCallback(async () => { try { const d = await unpack(await gc.get(`groups/${gid}/summaries`), '加载画像失败'); setSummaries(d || []) } catch (e: any) { toast.danger(e.message) } }, [gid]) useEffect(() => { loadParticipants() }, [loadParticipants]) useEffect(() => { if (tab === 'logs') loadLogs(1, logFilter) else if (tab === 'impressions') loadImpressions() else if (tab === 'summaries') loadSummaries() }, [tab]) // eslint-disable-line react-hooks/exhaustive-deps const addParticipant = async () => { if (!addUserId.trim()) return try { await gc.post(`groups/${gid}/participants`, { user_id: Number(addUserId.trim()) }); setAddUserId(''); loadParticipants() } catch (e: any) { toast.danger(e.message || '添加失败') } } const removeParticipant = async (uid: number) => { if (!window.confirm(`移除 ${uid}?其语料保留在库中`)) return try { await gc.del(`groups/${gid}/participants/${uid}`); loadParticipants() } catch (e: any) { toast.danger(e.message) } } const clearGroup = async () => { if (!window.confirm('确认清空该群全部数据(语料/印象/画像/参与者)?此操作不可恢复')) return try { await gc.del(`groups/${gid}/data`); toast.success('已清空'); loadParticipants(); loadLogs(1); loadImpressions(); loadSummaries() } catch (e: any) { toast.danger(e.message) } } const clearLogs = async () => { if (!window.confirm('删除该群全部语料?印象/画像保留')) return try { await gc.del(`groups/${gid}/logs`); loadLogs(1) } catch (e: any) { toast.danger(e.message) } } const pagination = Math.max(1, Math.ceil(logTotal / 50)) return (
群 #{gid} 管理
setTab(v as GalTab)} /> {tab === 'participants' && (
setAddUserId(e.target.value)} placeholder="加入人设名单" /> 仅名单内成员会被采集
{participants.map(p => ( ))} {!participants.length && }
QQ语料条数
{p.user_id}{p.corpus_count}
暂无参与者
)} {tab === 'logs' && (
setLogFilter(e.target.value)} placeholder="可选" />
{logs.map(r => ( ))} {!logs.length && }
ID发送者发给时间内容(已脱敏)
{r.id}{r.nickname} {r.user_id}{r.target_user_id ? '→ ' + r.target_user_id : '—'}{fmtTime(r.created_at)}{r.content}
暂无语料
第 {logPage} / {pagination} 页 · 共 {logTotal} 条
)} {tab === 'impressions' && ( {impressions.map(r => ( ))} {!impressions.length && }
ID用户覆盖语料区间时间印象内容
{r.id}{r.user_id}#{r.cover_from_id} ~ #{r.cover_to_id}{fmtTime(r.created_at)}{r.content}
暂无印象
)} {tab === 'summaries' && (
{summaries.map(s => (
v{s.version} · {fmtTime(s.created_at)} · 语料 {s.corpus_count} 条 · 印象 {s.impression_count} 条
{s.card_text}
))} {!summaries.length &&

暂无画像(语料积累后由总结路径生成)

}
)}
) } function DataBrowser({ groups }: { groups: Group[] }) { const [gid, setGid] = useState('') const [tab, setTab] = useState('logs') const [logs, setLogs] = useState([]) const [impressions, setImpressions] = useState([]) const [summaries, setSummaries] = useState([]) const [err, setErr] = useState('') const load = useCallback(async () => { setErr('') if (!gid) return try { if (tab === 'logs') { const d = await unpack(await gc.get(`groups/${gid}/logs?page=1&per_page=100`), '加载失败'); setLogs((d && d.items) || []) } else if (tab === 'impressions') { const d = await unpack(await gc.get(`groups/${gid}/impressions?page=1&per_page=100`), '加载失败'); setImpressions((d && d.items) || []) } else { const d = await unpack(await gc.get(`groups/${gid}/summaries`), '加载失败'); setSummaries(d || []) } } catch (e: any) { setErr(e.message) } }, [gid, tab]) useEffect(() => { load() }, [load]) return ( 数据浏览
{err && {err}}
setTab(v as GalTab)} /> {tab === 'logs' && ( {logs.map(r => )} {!logs.length && }
ID发送者发给时间内容(已脱敏)
{r.id}{r.nickname} {r.user_id}{r.target_user_id ? '→ ' + r.target_user_id : '—'}{fmtTime(r.created_at)}{r.content}
{gid ? '暂无数据' : '请先选择群'}
)} {tab === 'impressions' && ( {impressions.map(r => )} {!impressions.length && }
ID用户覆盖语料区间时间印象内容
{r.id}{r.user_id}#{r.cover_from_id} ~ #{r.cover_to_id}{fmtTime(r.created_at)}{r.content}
{gid ? '暂无数据' : '请先选择群'}
)} {tab === 'summaries' && (
{summaries.map(s =>
v{s.version} · {fmtTime(s.created_at)}
{s.card_text}
)} {!summaries.length &&

{gid ? '暂无数据' : '请先选择群'}

}
)}
) } function SettingsView() { const [data, setData] = useState(null) useEffect(() => { gc.get('settings').then(res => { try { setData(unpack(res)) } catch { } }).catch(() => {}) }, []) const rows = data ? [ ['长文截断上限', (data.thresholds?.max_content_len ?? '—') + ' 字'], ['复读检测窗口', (data.thresholds?.repeat_window ?? '—') + ' 条'], ['刷屏采样间隔', (data.thresholds?.throttle_seconds ?? '—') + ' 秒'], ['单用户语料滚动上限', (data.thresholds?.rolling_window ?? '—') + ' 条'], ] : [] return ( 当前配置(只读) {!data ?

加载中…

: ( {rows.map(([k, v]) => )}
{k}{v}
)}

阈值与脱敏规则见插件 processor.py / DESIGN.md;后台访问由统一管理台 /hub 提供。

) }