assistant/console/App.tsx

236 lines
9.7 KiB
TypeScript
Raw Permalink 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, useMemo, useState } from 'react';
import { Check, RefreshCw, Sparkles, X } from 'lucide-react';
import { api, errorText, setToken } from './api';
import { AutoReview } from './components/AutoReview';
import { EntryCard } from './components/EntryCard';
import { Schools } from './components/Schools';
import { CATEGORY_LABEL, STATUS_LABEL, type Entry, type EntryStatus, type Meta, type Scope, type School, type Stats } from './types';
type Tab = 'queue' | 'auto' | 'schools';
export function App() {
const [tab, setTab] = useState<Tab>('queue');
const [meta, setMeta] = useState<Meta | null>(null);
const [stats, setStats] = useState<Stats | null>(null);
const [entries, setEntries] = useState<Entry[]>([]);
const [schools, setSchools] = useState<School[]>([]);
const [status, setStatus] = useState<EntryStatus>('draft');
const [category, setCategory] = useState('');
const [school, setSchool] = useState('');
const [query, setQuery] = useState('');
const [autoOnly, setAutoOnly] = useState(false);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [tokenInput, setTokenInput] = useState('');
const [needToken, setNeedToken] = useState(false);
const say = useCallback((text: string) => {
setMessage(text);
window.setTimeout(() => setMessage(current => (current === text ? '' : current)), 4000);
}, []);
const load = useCallback(async (): Promise<void> => {
const params = new URLSearchParams({ status, limit: '300' });
if (category) params.set('category', category);
if (school) params.set('school_id', school);
if (query.trim()) params.set('q', query.trim());
try {
const [rows, overview, schoolRows] = await Promise.all([
api.get<Entry[]>(`/api/entries?${params}`),
api.get<Stats>('/api/stats'),
api.get<School[]>('/api/schools'),
]);
setEntries(autoOnly ? rows.filter(r => r.reviewed_by === 'auto') : rows);
setStats(overview);
setSchools(schoolRows);
setNeedToken(false);
setSelected(new Set());
} catch (error) {
const text = errorText(error);
if (text.includes('口令')) setNeedToken(true);
else say(text);
}
}, [status, category, school, query, autoOnly, say]);
useEffect(() => {
api.get<Meta>('/api/meta').then(setMeta).catch(() => undefined);
}, []);
useEffect(() => {
void load();
}, [load]);
async function withBusy(fn: () => Promise<void>): Promise<void> {
setBusy(true);
try {
await fn();
} catch (error) {
say(errorText(error));
} finally {
setBusy(false);
}
}
async function review(id: number, action: 'verify' | 'reject' | 'reset', value?: string, scope?: Scope): Promise<void> {
await withBusy(async () => {
await api.post(`/api/entries/${id}/review`, { action, value, scope });
await load();
});
}
async function batch(action: 'verify' | 'reject'): Promise<void> {
const ids = [...selected];
if (ids.length === 0) return;
await withBusy(async () => {
const result = await api.post<{ updated: number }>('/api/entries/batch-review', { ids, action });
say(`${action === 'verify' ? '采纳' : '驳回'}了 ${result.updated} 条`);
await load();
});
}
async function suggest(ids: number[]): Promise<void> {
await withBusy(async () => {
const result = await api.post<{ done: number; requested: number }>('/api/entries/presuggest', { ids });
say(`预审完成 ${result.done} / ${result.requested} 条`);
await load();
});
}
const counts = stats?.by_status ?? {};
const visibleIds = useMemo(() => entries.map(e => e.id), [entries]);
const allSelected = visibleIds.length > 0 && visibleIds.every(id => selected.has(id));
if (needToken) {
return (
<div className="gate">
<form onSubmit={event => { event.preventDefault(); setToken(tokenInput); void load(); }}>
<h1>经验审核台</h1>
<p className="muted">服务端配置了后台口令,请输入 .env 里的 HUB_CONSOLE_TOKEN。</p>
<input type="password" value={tokenInput} placeholder="后台口令"
onChange={event => setTokenInput(event.target.value)} autoFocus />
<button type="submit" className="primary-button">进入</button>
</form>
</div>
);
}
return (
<div className="console">
<header className="topbar">
<h1>经验审核台</h1>
<nav className="tabs">
<button type="button" className={tab === 'queue' ? 'active' : ''} onClick={() => setTab('queue')}>
审核队列{counts.draft ? ` · ${counts.draft}` : ''}
</button>
<button type="button" className={tab === 'auto' ? 'active' : ''} onClick={() => setTab('auto')}>
智能采纳
</button>
<button type="button" className={tab === 'schools' ? 'active' : ''} onClick={() => setTab('schools')}>
学校 · {stats?.schools ?? 0}
</button>
</nav>
<span className="spacer" />
{message && <span className="toast" role="status">{message}</span>}
<span className="muted small">
{meta?.llm_ready ? `智能预审:${meta.llm_model}` : '智能预审:仅规则(未配模型 Key)'}
</span>
<button type="button" className="icon-button" aria-label="刷新" onClick={() => { void load(); }}>
<RefreshCw size={16} />
</button>
</header>
{tab === 'schools' ? (
<main className="body">
<Schools schools={schools} onChanged={() => { void load(); }} onMessage={say} />
</main>
) : tab === 'auto' ? (
<main className="body">
<AutoReview
llmReady={Boolean(meta?.llm_ready)}
onApplied={() => { void load(); setTab('queue'); }}
onMessage={say}
/>
</main>
) : (
<main className="body">
<section className="overview">
{(['draft', 'verified', 'rejected'] as EntryStatus[]).map(s => (
<button key={s} type="button" className={`stat${status === s ? ' active' : ''}`} onClick={() => setStatus(s)}>
<span className="stat-n">{counts[s] ?? 0}</span>
<span className="stat-l">{STATUS_LABEL[s]}</span>
</button>
))}
<div className="stat-split">
{Object.entries(stats?.draft_by_category ?? {}).map(([cat, n]) => (
<span key={cat} className="muted small">{CATEGORY_LABEL[cat] ?? cat} {n}</span>
))}
</div>
</section>
<section className="filters">
<select value={category} onChange={event => setCategory(event.target.value)} aria-label="类别">
<option value="">全部类别</option>
{Object.entries(CATEGORY_LABEL).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<select value={school} onChange={event => setSchool(event.target.value)} aria-label="学校">
<option value="">全部学校</option>
{schools.map(s => <option key={s.id} value={s.id}>{s.name || s.id}</option>)}
</select>
<input value={query} placeholder="搜索标题或内容" onChange={event => setQuery(event.target.value)} />
<label className="check">
<input type="checkbox" checked={autoOnly} onChange={event => setAutoOnly(event.target.checked)} />
只看自动处理的
</label>
<span className="spacer" />
{status === 'draft' && (
<>
<label className="select-all">
<input type="checkbox" checked={allSelected} onChange={() => {
setSelected(allSelected ? new Set() : new Set(visibleIds));
}} />
全选 {selected.size > 0 && `(${selected.size})`}
</label>
<button type="button" className="secondary-button small" disabled={busy || entries.length === 0}
onClick={() => { void suggest(selected.size > 0 ? [...selected] : visibleIds.slice(0, 30)); }}>
<Sparkles size={14} />{selected.size > 0 ? '预审选中' : '预审本页'}
</button>
<button type="button" className="danger-button small" disabled={busy || selected.size === 0}
onClick={() => { void batch('reject'); }}>
<X size={14} />批量驳回
</button>
<button type="button" className="primary-button small" disabled={busy || selected.size === 0}
onClick={() => { void batch('verify'); }}>
<Check size={14} />批量采纳
</button>
</>
)}
</section>
<section className="entries">
{entries.length === 0 && <p className="empty muted">这个筛选下没有条目。</p>}
{entries.map(entry => (
<EntryCard
key={entry.id}
entry={entry}
selected={selected.has(entry.id)}
busy={busy}
onToggle={() => setSelected(prev => {
const next = new Set(prev);
if (next.has(entry.id)) next.delete(entry.id);
else next.add(entry.id);
return next;
})}
onReview={(action, value, scope) => { void review(entry.id, action, value, scope); }}
onSuggest={() => { void suggest([entry.id]); }}
/>
))}
</section>
</main>
)}
</div>
);
}