356 lines
21 KiB
TypeScript
356 lines
21 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
||||
|
|
import { Button, Card, Input, Label, Select, ListBox, TextField, toast } from '@heroui/react'
|
|||
|
|
import { api } from '../../api/client'
|
|||
|
|
|
|||
|
|
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 { web_username: string; web_enable: boolean; 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>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 items-center justify-between">
|
|||
|
|
<div>
|
|||
|
|
<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 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}>
|
|||
|
|
<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>
|
|||
|
|
</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<'participants' | 'logs' | 'impressions' | 'summaries'>('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={['参与者', '语料', '印象', '画像']} active={{ participants: '参与者', logs: '语料', impressions: '印象', summaries: '画像' }[tab]} onChange={v => setTab(({ '参与者': 'participants', '语料': 'logs', '印象': 'impressions', '画像': 'summaries' } as any)[v])} />
|
|||
|
|
{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}><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}><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}><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<'logs' | 'impressions' | 'summaries'>('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={['语料', '印象', '画像']} active={{ logs: '语料', impressions: '印象', summaries: '画像' }[tab]} onChange={v => setTab(({ '语料': 'logs', '印象': 'impressions', '画像': 'summaries' } as any)[v])} />
|
|||
|
|
{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.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}><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 ? [
|
|||
|
|
['Web 登录账号', data.web_username],
|
|||
|
|
['Web 后台启用', data.web_enable ? '是' : '否'],
|
|||
|
|
['长文截断上限', (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;Web 账号密码在 .env 配置 WEB_USERNAME / WEB_PASSWORD。</p>
|
|||
|
|
</Card.Content>
|
|||
|
|
</Card>
|
|||
|
|
)
|
|||
|
|
}
|