Files
HeXi/hexi/web/src/plugins/galgame_card/index.tsx
T
sansenhoshiandClaude Code c53b210413 feat: revamp web hub — global connection status, array config editor, log streaming
- Global connection badge in app header (persistent dashboard SSE via ConnectionProvider)
- LogProvider keeps log data and SSE stream alive across page switches (no reload spinner)
- Config drawer: password reveal toggle, array-type row editor for list fields
- Plugin management: optimistic updates, race-free loading, reset all group overrides
- Logs: load-earlier pagination; backend tail-read optimization (before/end offsets)
- web_auth: Web password changes persist (sync_admin seeding only when .env has explicit key)
- Dashboard: collect in thread pool + 1s result cache
- ErrorBoundary + local Suspense: plugin chunk loading stays inside content area
- UI polish: table row hover, mobile header/breadcrumb fixes, wider config drawer
- config_standard: array type inference for list fields; array env skip
- random_jm_code / steam_info: array schema migration; README web frontend note

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 15:39:10 +08:00

345 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, number> }
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<Group[]>([])
const [activeGid, setActiveGid] = useState<number | null>(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 (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<h2 className="text-xl font-semibold text-slate-800">群聊人设卡</h2>
<p className="text-sm text-slate-400">群采集开关 · 参与者 · 语料/印象/画像浏览</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-3 md:justify-end">
<div className="flex gap-1">
{(['groups', 'data', 'settings'] as const).map(v => (
<Button key={v} size="sm" variant={view === v ? 'primary' : 'tertiary'} onPress={() => setView(v)}>
{v === 'groups' ? '群管理' : v === 'data' ? '数据浏览' : '设置'}
</Button>
))}
</div>
<Button size="sm" isDisabled={loading} onPress={() => { setView('groups'); loadGroups() }}>{loading ? '加载中…' : '刷新'}</Button>
</div>
</div>
{error && <Card className="border border-red-200 bg-red-50"><Card.Content><p className="text-sm text-red-600">{error}</p></Card.Content></Card>}
{view === 'groups' && (
<Card className="border border-slate-200 bg-white">
<Card.Header><Card.Title>群采集开关</Card.Title></Card.Header>
<Card.Content>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px]">
<thead><tr>
<th className={thCls}>群</th><th className={thCls}>开关</th><th className={thCls}>参与者</th><th className={thCls}>语料</th><th className={thCls}>印象</th><th className={thCls}>画像</th><th className={thCls}></th>
</tr></thead>
<tbody>
{groups.map(g => (
<tr key={g.group_id} className="transition-colors hover:bg-slate-50">
<td className={tdCls}><div className="font-medium text-slate-800">{g.group_name || '(未命名群)'}</div><div className="text-xs text-slate-400">{g.group_id}</div></td>
<td className={tdCls}>
<Switch size="sm" isSelected={g.enabled} onChange={(v) => toggleGroup(g, v)}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control></Switch.Content>
</Switch>
</td>
<td className={tdCls}>{g.participants}</td><td className={tdCls}>{g.corpus_count}</td><td className={tdCls}>{g.impression_count}</td><td className={tdCls}>{g.summary_count}</td>
<td className={tdCls + ' text-right'}><Button size="sm" variant="tertiary" onPress={() => openDetail(g)}>管理</Button></td>
</tr>
))}
{!groups.length && <tr><td colSpan={7} className={tdCls + ' text-slate-400 text-center'}>暂无管理中的群,请先在「添加群」中添加(或等待数据采集自动登记)</td></tr>}
</tbody>
</table>
</div>
</Card.Content>
</Card>
)}
{view === 'detail' && activeGid !== null && <GroupDetail gid={activeGid} onBack={() => setView('groups')} />}
{view === 'data' && <DataBrowser groups={groups} />}
{view === 'settings' && <SettingsView />}
</div>
)
}
function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
const [tab, setTab] = useState<GalTab>('participants')
const [participants, setParticipants] = useState<Participant[]>([])
const [logs, setLogs] = useState<LogRow[]>([])
const [logTotal, setLogTotal] = useState(0)
const [logPage, setLogPage] = useState(1)
const [impressions, setImpressions] = useState<ImpressionRow[]>([])
const [summaries, setSummaries] = useState<SummaryRow[]>([])
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button size="sm" variant="tertiary" onPress={onBack}>← 返回</Button>
<span className="text-sm font-semibold text-slate-800">群 #{gid} 管理</span>
</div>
<Button size="sm" variant="tertiary" className="text-red-500" onPress={clearGroup}>清空该群全部数据</Button>
</div>
<Card className="border border-slate-200 bg-white">
<Card.Content>
<Tabs tabs={GAL_TABS} active={tab} onChange={v => setTab(v as GalTab)} />
{tab === 'participants' && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<TextField><Label>QQ 号</Label><Input value={addUserId} onChange={e => setAddUserId(e.target.value)} placeholder="加入人设名单" /></TextField>
<Button size="sm" onPress={addParticipant}>加入人设名单</Button>
<span className="text-xs text-slate-400">仅名单内成员会被采集</span>
</div>
<table className="w-full">
<thead><tr><th className={thCls}>QQ</th><th className={thCls}>语料条数</th><th className={thCls}></th></tr></thead>
<tbody>
{participants.map(p => (
<tr key={p.user_id} className="transition-colors hover:bg-slate-50"><td className={tdCls}>{p.user_id}</td><td className={tdCls}>{p.corpus_count}</td><td className={tdCls + ' text-right'}><Button size="sm" variant="tertiary" className="text-red-500" onPress={() => removeParticipant(p.user_id)}>移除</Button></td></tr>
))}
{!participants.length && <tr><td colSpan={3} className={tdCls + ' text-slate-400 text-center'}>暂无参与者</td></tr>}
</tbody>
</table>
</div>
)}
{tab === 'logs' && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<TextField><Label>按 QQ 过滤</Label><Input value={logFilter} onChange={e => setLogFilter(e.target.value)} placeholder="可选" /></TextField>
<Button size="sm" onPress={() => loadLogs(1, logFilter)}>查询</Button>
<Button size="sm" variant="tertiary" className="text-red-500" onPress={clearLogs}>删除全部语料</Button>
</div>
<table className="w-full">
<thead><tr><th className={thCls}>ID</th><th className={thCls}>发送者</th><th className={thCls}>发给</th><th className={thCls}>时间</th><th className={thCls}>内容(已脱敏)</th></tr></thead>
<tbody>
{logs.map(r => (
<tr key={r.id} className="transition-colors hover:bg-slate-50"><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.nickname}<span className="text-xs text-slate-400"> {r.user_id}</span></td><td className={tdCls}>{r.target_user_id ? '→ ' + r.target_user_id : '—'}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>
))}
{!logs.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>暂无语料</td></tr>}
</tbody>
</table>
<div className="flex items-center gap-3 text-xs text-slate-400">
<Button size="sm" variant="tertiary" isDisabled={logPage <= 1} onPress={() => loadLogs(logPage - 1, logFilter)}>上一页</Button>
<span>第 {logPage} / {pagination} 页 · 共 {logTotal} 条</span>
<Button size="sm" variant="tertiary" isDisabled={logPage >= pagination} onPress={() => loadLogs(logPage + 1, logFilter)}>下一页</Button>
</div>
</div>
)}
{tab === 'impressions' && (
<table className="w-full">
<thead><tr><th className={thCls}>ID</th><th className={thCls}>用户</th><th className={thCls}>覆盖语料区间</th><th className={thCls}>时间</th><th className={thCls}>印象内容</th></tr></thead>
<tbody>
{impressions.map(r => (
<tr key={r.id} className="transition-colors hover:bg-slate-50"><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.user_id}</td><td className={tdCls}>#{r.cover_from_id} ~ #{r.cover_to_id}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>
))}
{!impressions.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>暂无印象</td></tr>}
</tbody>
</table>
)}
{tab === 'summaries' && (
<div className="space-y-3">
{summaries.map(s => (
<div key={s.version} className="rounded-lg border border-slate-200 bg-slate-50 p-3">
<div className="mb-1 text-xs text-slate-500">v{s.version} · {fmtTime(s.created_at)} · 语料 {s.corpus_count} 条 · 印象 {s.impression_count} 条</div>
<pre className="whitespace-pre-wrap text-xs text-slate-700">{s.card_text}</pre>
</div>
))}
{!summaries.length && <p className="text-sm text-slate-400">暂无画像(语料积累后由总结路径生成)</p>}
</div>
)}
</Card.Content>
</Card>
</div>
)
}
function DataBrowser({ groups }: { groups: Group[] }) {
const [gid, setGid] = useState<number | ''>('')
const [tab, setTab] = useState<GalTab>('logs')
const [logs, setLogs] = useState<LogRow[]>([])
const [impressions, setImpressions] = useState<ImpressionRow[]>([])
const [summaries, setSummaries] = useState<SummaryRow[]>([])
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 (
<Card className="border border-slate-200 bg-white">
<Card.Header><Card.Title>数据浏览</Card.Title></Card.Header>
<Card.Content className="space-y-3">
<div className="flex items-center gap-3">
<Select selectedKey={gid === '' ? '' : String(gid)} onSelectionChange={(k) => setGid(k ? Number(k) : '')} aria-label="选择群">
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
<Select.Popover><ListBox>
{groups.map(g => <ListBox.Item key={g.group_id} id={String(g.group_id)}>{g.group_name || g.group_id}({g.group_id})</ListBox.Item>)}
</ListBox></Select.Popover>
</Select>
{err && <span className="text-xs text-red-500">{err}</span>}
</div>
<Tabs tabs={DATA_TABS} active={tab} onChange={v => setTab(v as GalTab)} />
{tab === 'logs' && (
<table className="w-full">
<thead><tr><th className={thCls}>ID</th><th className={thCls}>发送者</th><th className={thCls}>发给</th><th className={thCls}>时间</th><th className={thCls}>内容(已脱敏)</th></tr></thead>
<tbody>
{logs.map(r => <tr key={r.id} className="transition-colors hover:bg-slate-50"><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.nickname}<span className="text-xs text-slate-400"> {r.user_id}</span></td><td className={tdCls}>{r.target_user_id ? '→ ' + r.target_user_id : '—'}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>)}
{!logs.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>{gid ? '暂无数据' : '请先选择群'}</td></tr>}
</tbody>
</table>
)}
{tab === 'impressions' && (
<table className="w-full">
<thead><tr><th className={thCls}>ID</th><th className={thCls}>用户</th><th className={thCls}>覆盖语料区间</th><th className={thCls}>时间</th><th className={thCls}>印象内容</th></tr></thead>
<tbody>
{impressions.map(r => <tr key={r.id} className="transition-colors hover:bg-slate-50"><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.user_id}</td><td className={tdCls}>#{r.cover_from_id} ~ #{r.cover_to_id}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>)}
{!impressions.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>{gid ? '暂无数据' : '请先选择群'}</td></tr>}
</tbody>
</table>
)}
{tab === 'summaries' && (
<div className="space-y-3">
{summaries.map(s => <div key={s.version} className="rounded-lg border border-slate-200 bg-slate-50 p-3"><div className="mb-1 text-xs text-slate-500">v{s.version} · {fmtTime(s.created_at)}</div><pre className="whitespace-pre-wrap text-xs text-slate-700">{s.card_text}</pre></div>)}
{!summaries.length && <p className="text-sm text-slate-400">{gid ? '暂无数据' : '请先选择群'}</p>}
</div>
)}
</Card.Content>
</Card>
)
}
function SettingsView() {
const [data, setData] = useState<SettingsData | null>(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 (
<Card className="border border-slate-200 bg-white">
<Card.Header><Card.Title>当前配置(只读)</Card.Title></Card.Header>
<Card.Content>
{!data ? <p className="text-sm text-slate-400">加载中…</p> : (
<table className="w-full max-w-[560px]">
<tbody>{rows.map(([k, v]) => <tr key={k}><td className={tdCls + ' w-48 text-slate-400'}>{k}</td><td className={tdCls}>{v}</td></tr>)}</tbody>
</table>
)}
<p className="mt-3 text-xs text-slate-400">阈值与脱敏规则见插件 processor.py / DESIGN.md;后台访问由统一管理台 /hub 提供。</p>
</Card.Content>
</Card>
)
}