236 lines
9.7 KiB
TypeScript
236 lines
9.7 KiB
TypeScript
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>
|
||
);
|
||
}
|