Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
76 lines
2.9 KiB
TypeScript
76 lines
2.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { X } from "lucide-react";
|
|
|
|
import { REPLY_QUALITY_NOTE_MAX, REPLY_QUALITY_REASON_LABELS, REPLY_QUALITY_REASONS } from "@/lib/reply-quality-labels";
|
|
import { notifyError, notifySuccess } from "@/lib/notify";
|
|
import { saveReplyQualityReason } from "@/lib/reply-ratings";
|
|
|
|
const REASONS = REPLY_QUALITY_REASONS.map((key) => [key, REPLY_QUALITY_REASON_LABELS[key]] as const);
|
|
|
|
/**
|
|
* 不满意原因 after a 👎 (compliance round 2026-09-30). Everything here is
|
|
* optional: the 👎 and the turn's snapshot are already saved when this opens;
|
|
* closing it keeps them. One line says the turn is sent for review. The thanks
|
|
* (or a failure) is a toast; the panel closes itself on success (2026-09-30).
|
|
*/
|
|
export function ReplyDownReason({ feedbackKey, onClose }: Readonly<{ feedbackKey: string; onClose: () => void }>) {
|
|
const [picked, setPicked] = useState<ReadonlySet<string>>(() => new Set());
|
|
const [note, setNote] = useState("");
|
|
const [state, setState] = useState<"idle" | "sending" | "failed">("idle");
|
|
|
|
async function submit() {
|
|
if (state === "sending") return;
|
|
setState("sending");
|
|
const ok = await saveReplyQualityReason(feedbackKey, REASONS.map(([key]) => key).filter((key) => picked.has(key)), note);
|
|
if (ok) {
|
|
notifySuccess("已收到,谢谢。");
|
|
onClose();
|
|
return;
|
|
}
|
|
setState("failed");
|
|
notifyError("没能提交,再试一次。");
|
|
}
|
|
|
|
return (
|
|
<div className="reply-down-reason" role="group" aria-label="不满意原因">
|
|
<div className="reply-down-reason-head">
|
|
<span>哪里不满意?可以不选</span>
|
|
<button type="button" className="reply-down-reason-close" aria-label="关闭" onClick={onClose}><X aria-hidden="true" /></button>
|
|
</div>
|
|
<div className="reply-down-reason-chips">
|
|
{REASONS.map(([key, label]) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
aria-pressed={picked.has(key)}
|
|
onClick={() => setPicked((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(key)) next.delete(key); else next.add(key);
|
|
return next;
|
|
})}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<input
|
|
className="reply-down-reason-note"
|
|
type="text"
|
|
value={note}
|
|
maxLength={REPLY_QUALITY_NOTE_MAX}
|
|
placeholder="补充一句(可不填)"
|
|
aria-label="补充说明"
|
|
onChange={(event) => setNote(event.target.value)}
|
|
/>
|
|
<div className="reply-down-reason-foot">
|
|
<span>会把这一轮对话发给我们排查</span>
|
|
<button type="button" className="reply-down-reason-send" disabled={state === "sending"} onClick={() => void submit()}>
|
|
{state === "failed" ? "再试一次" : "提交"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|