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('queue'); const [meta, setMeta] = useState(null); const [stats, setStats] = useState(null); const [entries, setEntries] = useState([]); const [schools, setSchools] = useState([]); const [status, setStatus] = useState('draft'); const [category, setCategory] = useState(''); const [school, setSchool] = useState(''); const [query, setQuery] = useState(''); const [autoOnly, setAutoOnly] = useState(false); const [selected, setSelected] = useState>(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 => { 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(`/api/entries?${params}`), api.get('/api/stats'), api.get('/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('/api/meta').then(setMeta).catch(() => undefined); }, []); useEffect(() => { void load(); }, [load]); async function withBusy(fn: () => Promise): Promise { 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 { await withBusy(async () => { await api.post(`/api/entries/${id}/review`, { action, value, scope }); await load(); }); } async function batch(action: 'verify' | 'reject'): Promise { 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 { 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 (
{ event.preventDefault(); setToken(tokenInput); void load(); }}>

经验审核台

服务端配置了后台口令,请输入 .env 里的 HUB_CONSOLE_TOKEN。

setTokenInput(event.target.value)} autoFocus />
); } return (

经验审核台

{message && {message}} {meta?.llm_ready ? `智能预审:${meta.llm_model}` : '智能预审:仅规则(未配模型 Key)'}
{tab === 'schools' ? (
{ void load(); }} onMessage={say} />
) : tab === 'auto' ? (
{ void load(); setTab('queue'); }} onMessage={say} />
) : (
{(['draft', 'verified', 'rejected'] as EntryStatus[]).map(s => ( ))}
{Object.entries(stats?.draft_by_category ?? {}).map(([cat, n]) => ( {CATEGORY_LABEL[cat] ?? cat} {n} ))}
setQuery(event.target.value)} /> {status === 'draft' && ( <> )}
{entries.length === 0 &&

这个筛选下没有条目。

} {entries.map(entry => ( 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]); }} /> ))}
)}
); }