Files
hunjing/frontend/src/components/characters/CandidateReviewPanel.tsx
T
2026-08-21 16:00:13 +08:00

159 lines
5.4 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader } from "@/components/ui/Card";
import { extractionApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import { formatDateTime } from "@/lib/utils/format";
import type { MemoryCandidate, WorldEntity } from "@/types/api";
/**
* 候选审核面板(跨来源统一入口)。
* 记忆候选(cycle 大周天 / import 资料导入)与世界实体候选逐条 approve/reject。
* 审核后 router.refresh()。与 ImportWizard 的批量审核互补——此处为跨来源统一待审队列。
*/
export function CandidateReviewPanel({
memoryCandidates,
worldProposals,
}: {
memoryCandidates: MemoryCandidate[];
worldProposals: WorldEntity[];
}) {
const router = useRouter();
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function reviewMemory(id: string, approve: boolean) {
setBusyId(id);
setError(null);
try {
if (approve) await extractionApi.approveMemoryCandidate(id);
else await extractionApi.rejectMemoryCandidate(id);
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusyId(null);
}
}
async function reviewWorld(id: string, approve: boolean) {
setBusyId(id);
setError(null);
try {
if (approve) await extractionApi.approveWorldEntity(id);
else await extractionApi.rejectWorldEntity(id);
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusyId(null);
}
}
return (
<div className="space-y-4">
<Card className="p-5">
<CardHeader
title="记忆候选"
subtitle="来自大周天反思与资料导入的待审记忆,逐条裁定。"
/>
{memoryCandidates.length === 0 ? (
<p className="text-sm text-muted">暂无待审记忆候选。</p>
) : (
<ul className="space-y-2">
{memoryCandidates.map((c) => (
<li key={c.id} className="rounded-lg bg-glass/40 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded bg-gold/15 px-1.5 py-0.5 text-[10px] text-gold">
{c.type}
</span>
<span className="font-serif text-sm font-medium text-fg">
{c.title}
</span>
<span className="text-[10px] text-muted">
{c.import_job_id ? "资料导入" : c.cycle_id ? "大周天" : ""}
{` · ${formatDateTime(c.created_at)}`}
</span>
<span className="ml-auto font-mono text-[10px] text-muted">
{(c.confidence ?? 0).toFixed(2)}
</span>
</div>
<p className="mt-1 text-xs text-fg/80">{c.content}</p>
<div className="mt-1.5 flex gap-2">
<Button
variant="primary"
size="sm"
loading={busyId === c.id}
onClick={() => reviewMemory(c.id, true)}
>
通过
</Button>
<Button
variant="danger"
size="sm"
loading={busyId === c.id}
onClick={() => reviewMemory(c.id, false)}
>
驳回
</Button>
</div>
</li>
))}
</ul>
)}
</Card>
<Card className="p-5">
<CardHeader
title="世界实体候选"
subtitle="待确认的世界实体,通过后进入世界模型(confirmed)。"
/>
{worldProposals.length === 0 ? (
<p className="text-sm text-muted">暂无待审世界实体候选。</p>
) : (
<ul className="space-y-2">
{worldProposals.map((e) => (
<li key={e.id} className="rounded-lg bg-glass/40 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded bg-gold/15 px-1.5 py-0.5 text-[10px] text-gold">
{e.type}
</span>
<span className="font-serif text-sm font-medium text-fg">
{e.name}
</span>
<span className="ml-auto font-mono text-[10px] text-muted">
{e.confidence.toFixed(2)}
</span>
</div>
<div className="mt-1.5 flex gap-2">
<Button
variant="primary"
size="sm"
loading={busyId === e.id}
onClick={() => reviewWorld(e.id, true)}
>
通过
</Button>
<Button
variant="danger"
size="sm"
loading={busyId === e.id}
onClick={() => reviewWorld(e.id, false)}
>
驳回
</Button>
</div>
</li>
))}
</ul>
)}
</Card>
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
);
}