143 lines
5.5 KiB
TypeScript
143 lines
5.5 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import type {
|
||
FeedbackCenterRecord,
|
||
FeedbackCenterReviewStatus,
|
||
FeedbackReasonCandidate,
|
||
} from "@/api/feedbackCenter";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
|
||
interface ReasonPickerProps {
|
||
record: FeedbackCenterRecord | null;
|
||
catalog: FeedbackReasonCandidate[];
|
||
isPending: boolean;
|
||
onSubmit: (payload: {
|
||
review_status: Exclude<FeedbackCenterReviewStatus, "pending">;
|
||
reason_code?: string;
|
||
reason_text?: string;
|
||
}) => Promise<void> | void;
|
||
}
|
||
|
||
export function ReasonPicker({ record, catalog, isPending, onSubmit }: ReasonPickerProps) {
|
||
const [selectedReason, setSelectedReason] = useState<string>("");
|
||
const [reasonText, setReasonText] = useState("");
|
||
const [targetStatus, setTargetStatus] = useState<Exclude<FeedbackCenterReviewStatus, "pending">>("accepted");
|
||
|
||
const candidates = useMemo(() => {
|
||
if (!record) return [];
|
||
const base = Array.isArray(record.reason_candidates) ? record.reason_candidates : [];
|
||
const merged = [...base];
|
||
catalog.forEach((item) => {
|
||
if (merged.some((candidate) => candidate.code === item.code)) return;
|
||
merged.push(item);
|
||
});
|
||
return merged;
|
||
}, [catalog, record]);
|
||
|
||
useEffect(() => {
|
||
setSelectedReason(record?.reason_code || "");
|
||
setReasonText(record?.reason_text || "");
|
||
if (record?.review_status === "reviewed" || record?.review_status === "rejected") {
|
||
setTargetStatus(record.review_status);
|
||
return;
|
||
}
|
||
setTargetStatus("accepted");
|
||
}, [record?.id, record?.reason_code, record?.reason_text, record?.review_status]);
|
||
|
||
if (!record) {
|
||
return (
|
||
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-5 text-sm text-slate-500">
|
||
选择左侧记录后,这里会显示候选原因与学习确认操作。
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Review Action</p>
|
||
<h3 className="text-lg font-semibold text-slate-900">确认学习原因</h3>
|
||
<p className="text-sm text-slate-500">AI 已经先给出候选原因,人只需要确认、修正,或拒绝纳入学习。</p>
|
||
</div>
|
||
|
||
<div className="mt-5 space-y-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
{(["reviewed", "accepted", "rejected"] as const).map((status) => (
|
||
<Button
|
||
key={status}
|
||
type="button"
|
||
size="sm"
|
||
variant={targetStatus === status ? "default" : "outline"}
|
||
className={targetStatus === status ? "bg-slate-900 text-white hover:bg-slate-800" : ""}
|
||
onClick={() => setTargetStatus(status)}
|
||
>
|
||
{status === "reviewed" ? "仅复核" : status === "accepted" ? "确认学习" : "拒绝样本"}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<p className="text-sm font-medium text-slate-700">候选原因</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{candidates.map((candidate) => {
|
||
const active = selectedReason === candidate.code;
|
||
return (
|
||
<button
|
||
key={candidate.code}
|
||
type="button"
|
||
className={`rounded-full border px-3 py-1 text-xs transition ${
|
||
active
|
||
? "border-slate-900 bg-slate-900 text-white"
|
||
: "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100"
|
||
}`}
|
||
onClick={() => setSelectedReason(candidate.code)}
|
||
>
|
||
{candidate.label}
|
||
{candidate.confidence !== undefined && candidate.confidence !== null && (
|
||
<span className={`ml-1 ${active ? "text-slate-200" : "text-slate-400"}`}>
|
||
{Math.round(Number(candidate.confidence) * 100)}%
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
{!candidates.length && (
|
||
<Badge variant="outline" className="border-amber-200 bg-amber-50 text-amber-700">
|
||
当前没有自动候选,请手工补充原因
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<p className="text-sm font-medium text-slate-700">补充备注</p>
|
||
<Textarea
|
||
value={reasonText}
|
||
onChange={(event) => setReasonText(event.target.value)}
|
||
placeholder="例如:依赖链路还没锁定,所以负责人不该提前自动落库。"
|
||
className="min-h-[110px] resize-none border-slate-200"
|
||
/>
|
||
</div>
|
||
|
||
<Button
|
||
type="button"
|
||
className="w-full bg-blue-600 text-white hover:bg-blue-700"
|
||
disabled={isPending}
|
||
onClick={() =>
|
||
Promise.resolve(
|
||
onSubmit({
|
||
review_status: targetStatus,
|
||
reason_code: selectedReason || undefined,
|
||
reason_text: reasonText.trim() || undefined,
|
||
})
|
||
)
|
||
}
|
||
>
|
||
{isPending ? "提交中..." : targetStatus === "accepted" ? "确认进入学习池" : targetStatus === "rejected" ? "拒绝该样本" : "保存复核结果"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|