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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button, Card, Input, Label, Select, ListBox, TextField, toast } from '@heroui/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')
|
||||
|
||||
@@ -12,32 +13,23 @@ 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 { web_username: string; web_enable: boolean; thresholds: Record<string, number> }
|
||||
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
|
||||
}
|
||||
|
||||
const selectCls = 'bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
|
||||
const inputCls = 'w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
|
||||
const thCls = 'px-3 py-2 text-left text-xs font-medium text-slate-400'
|
||||
const tdCls = 'px-3 py-2 text-sm text-slate-700 border-t border-slate-100'
|
||||
|
||||
function fmtTime(ts: string) { return ts || '—' }
|
||||
|
||||
function Tabs({ tabs, active, onChange }: { tabs: string[]; active: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex gap-1 border-b border-slate-200 mb-4">
|
||||
{tabs.map(t => (
|
||||
<button key={t} onClick={() => onChange(t)}
|
||||
className={'px-3 py-2 text-sm border-b-2 -mb-px ' + (active === t ? 'border-indigo-500 text-indigo-700 font-medium' : 'border-transparent text-slate-500 hover:text-slate-700')}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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')
|
||||
@@ -65,12 +57,12 @@ export default function GalgameCardPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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 items-center gap-3">
|
||||
<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)}>
|
||||
@@ -95,13 +87,12 @@ export default function GalgameCardPage() {
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{groups.map(g => (
|
||||
<tr key={g.group_id}>
|
||||
<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}>
|
||||
<button onClick={() => toggleGroup(g, !g.enabled)}
|
||||
className={'relative h-5 w-10 rounded-full transition-colors ' + (g.enabled ? 'bg-emerald-500' : 'bg-slate-300')}>
|
||||
<span className={'absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all ' + (g.enabled ? 'left-5' : 'left-0.5')} />
|
||||
</button>
|
||||
<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>
|
||||
@@ -124,7 +115,7 @@ export default function GalgameCardPage() {
|
||||
|
||||
|
||||
function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
|
||||
const [tab, setTab] = useState<'participants' | 'logs' | 'impressions' | 'summaries'>('participants')
|
||||
const [tab, setTab] = useState<GalTab>('participants')
|
||||
const [participants, setParticipants] = useState<Participant[]>([])
|
||||
const [logs, setLogs] = useState<LogRow[]>([])
|
||||
const [logTotal, setLogTotal] = useState(0)
|
||||
@@ -191,7 +182,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
|
||||
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Content>
|
||||
<Tabs tabs={['参与者', '语料', '印象', '画像']} active={{ participants: '参与者', logs: '语料', impressions: '印象', summaries: '画像' }[tab]} onChange={v => setTab(({ '参与者': 'participants', '语料': 'logs', '印象': 'impressions', '画像': 'summaries' } as any)[v])} />
|
||||
<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">
|
||||
@@ -203,7 +194,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
|
||||
<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}><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>
|
||||
<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>
|
||||
@@ -221,7 +212,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
|
||||
<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}><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>
|
||||
<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>
|
||||
@@ -238,7 +229,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
|
||||
<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}><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>
|
||||
<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>
|
||||
@@ -264,7 +255,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
|
||||
|
||||
function DataBrowser({ groups }: { groups: Group[] }) {
|
||||
const [gid, setGid] = useState<number | ''>('')
|
||||
const [tab, setTab] = useState<'logs' | 'impressions' | 'summaries'>('logs')
|
||||
const [tab, setTab] = useState<GalTab>('logs')
|
||||
const [logs, setLogs] = useState<LogRow[]>([])
|
||||
const [impressions, setImpressions] = useState<ImpressionRow[]>([])
|
||||
const [summaries, setSummaries] = useState<SummaryRow[]>([])
|
||||
@@ -295,12 +286,12 @@ function DataBrowser({ groups }: { groups: Group[] }) {
|
||||
</Select>
|
||||
{err && <span className="text-xs text-red-500">{err}</span>}
|
||||
</div>
|
||||
<Tabs tabs={['语料', '印象', '画像']} active={{ logs: '语料', impressions: '印象', summaries: '画像' }[tab]} onChange={v => setTab(({ '语料': 'logs', '印象': 'impressions', '画像': 'summaries' } as any)[v])} />
|
||||
<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}><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.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>
|
||||
@@ -309,7 +300,7 @@ function DataBrowser({ groups }: { groups: Group[] }) {
|
||||
<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}><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.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>
|
||||
@@ -332,8 +323,6 @@ function SettingsView() {
|
||||
gc.get('settings').then(res => { try { setData(unpack(res)) } catch { } }).catch(() => {})
|
||||
}, [])
|
||||
const rows = data ? [
|
||||
['Web 登录账号', data.web_username],
|
||||
['Web 后台启用', data.web_enable ? '是' : '否'],
|
||||
['长文截断上限', (data.thresholds?.max_content_len ?? '—') + ' 字'],
|
||||
['复读检测窗口', (data.thresholds?.repeat_window ?? '—') + ' 条'],
|
||||
['刷屏采样间隔', (data.thresholds?.throttle_seconds ?? '—') + ' 秒'],
|
||||
@@ -348,7 +337,7 @@ function SettingsView() {
|
||||
<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;Web 账号密码在 .env 配置 WEB_USERNAME / WEB_PASSWORD。</p>
|
||||
<p className="mt-3 text-xs text-slate-400">阈值与脱敏规则见插件 processor.py / DESIGN.md;后台访问由统一管理台 /hub 提供。</p>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user