PMFlow/frontend/src/pages/ai-steward/components/feedback-center/ReasonPicker.tsx

143 lines
5.5 KiB
TypeScript
Raw 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 { 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>
);
}