feat(support): in-app feedback & complaints with admin handling; persist reply ratings

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-30 09:42:11 +08:00
co-authored by Claude Opus 5.5
parent 8f58eccecf
commit 3c8123d912
24 changed files with 1244 additions and 1 deletions
@@ -112,6 +112,7 @@ export function AdminApp({ children }: { children: ReactNode }) {
{ name: "consultations", list: "/admin/consultations", meta: { label: "咨询请求", icon: <MessageOutlined /> } },
{ name: "usage", list: "/admin/usage", meta: { label: "用量与成本", icon: <ExperimentOutlined /> } },
{ name: "rectification-telemetry", list: "/admin/rectification-telemetry", meta: { label: "校正统计", icon: <BarChartOutlined /> } },
{ name: "feedback", list: "/admin/feedback", meta: { label: "反馈与投诉", icon: <MessageOutlined /> } },
{ name: "models", list: "/admin/models", meta: { label: "模型配置", icon: <ApiOutlined /> } },
{ name: "feature-pricing", list: "/admin/feature-pricing", meta: { label: "功能定价", icon: <CreditCardOutlined /> } },
{ name: "pricing-simulator", list: "/admin/pricing-simulator", meta: { label: "定价测算", icon: <ExperimentOutlined /> } },
@@ -0,0 +1,125 @@
"use client";
import { useGetIdentity, useInvalidate } from "@refinedev/core";
import { App, Button, Input, Modal, Select, Space, Tag, Typography, type TableColumnsType } from "antd";
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import {
FEEDBACK_STATUS_LABELS,
FEEDBACK_STATUSES,
FEEDBACK_TYPE_LABELS,
FEEDBACK_TYPES,
type FeedbackStatus,
type FeedbackType,
} from "@/lib/user-feedback";
import { formatAdminDate, ResourceTable } from "./resource-table";
const { Text, Paragraph } = Typography;
type Feedback = {
id: string;
userId: string;
email: string | null;
type: FeedbackType;
body: string;
contact: string | null;
sessionId: string | null;
status: FeedbackStatus;
adminNote: string | null;
createdAt: string;
updatedAt: string;
};
const STATUS_COLOR: Record<FeedbackStatus, string> = { new: "gold", in_progress: "blue", resolved: "green", rejected: "default" };
/**
* 反馈与投诉 (compliance round 2026-09-30). New content reports sort first
* and are tagged red. Handling = a status plus an internal note; every change
* is audited by the database function.
*/
export function FeedbackResource() {
const { message } = App.useApp();
const { data: identity } = useGetIdentity<AdminIdentity>();
const invalidate = useInvalidate();
const canHandle = Boolean(identity?.permissions.includes("support.feedback.write"));
const [editing, setEditing] = useState<Feedback | null>(null);
const [status, setStatus] = useState<FeedbackStatus>("in_progress");
const [note, setNote] = useState("");
const [saving, setSaving] = useState(false);
function open(item: Feedback) {
setEditing(item);
setStatus(item.status === "new" ? "in_progress" : item.status);
setNote(item.adminNote ?? "");
}
async function save() {
if (!editing) return;
setSaving(true);
try {
await adminRequestJson("/api/admin/feedback", {
method: "POST",
headers: { "x-request-id": crypto.randomUUID() },
body: JSON.stringify({ id: editing.id, status, adminNote: note }),
});
await invalidate({ resource: "feedback", invalidates: ["list"] });
message.success("已更新处理状态");
setEditing(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "更新失败");
} finally {
setSaving(false);
}
}
const columns: TableColumnsType<Feedback> = [
{
title: "类型",
render: (_, item) => <Tag color={item.type === "content_report" ? "red" : "default"}>{FEEDBACK_TYPE_LABELS[item.type]}</Tag>,
},
{
title: "内容",
render: (_, item) => <Paragraph ellipsis={{ rows: 2, expandable: true }} style={{ maxWidth: 420, marginBottom: 0 }}>{item.body}</Paragraph>,
},
{ title: "用户", render: (_, item) => item.email ?? item.userId },
{ title: "联系方式", dataIndex: "contact", render: (value) => value ?? "—" },
{ title: "附带会话", dataIndex: "sessionId", render: (value) => (value ? <Text copyable>{value}</Text> : "—") },
{ title: "状态", render: (_, item) => <Tag color={STATUS_COLOR[item.status]}>{FEEDBACK_STATUS_LABELS[item.status]}</Tag> },
{ title: "提交时间", dataIndex: "createdAt", render: formatAdminDate },
{
title: "处理",
fixed: "right",
render: (_, item) => (canHandle ? <Button size="small" onClick={() => open(item)}>处理</Button> : null),
},
];
return (
<>
<ResourceTable<Feedback>
resource="feedback"
title="反馈与投诉"
columns={columns}
statusOptions={[
...FEEDBACK_STATUSES.map((value) => ({ value, label: FEEDBACK_STATUS_LABELS[value] })),
...FEEDBACK_TYPES.map((value) => ({ value: `type:${value}`, label: `类型:${FEEDBACK_TYPE_LABELS[value]}` })),
]}
/>
<Modal title="处理反馈" open={editing !== null} onCancel={() => setEditing(null)} onOk={() => void save()} okText="保存" confirmLoading={saving}>
{editing ? (
<Space direction="vertical" style={{ width: "100%" }}>
<Text type="secondary">{FEEDBACK_TYPE_LABELS[editing.type]} · {formatAdminDate(editing.createdAt)}</Text>
<Paragraph>{editing.body}</Paragraph>
<Select<FeedbackStatus>
value={status}
onChange={setStatus}
options={FEEDBACK_STATUSES.map((value) => ({ value, label: FEEDBACK_STATUS_LABELS[value] }))}
style={{ width: 200 }}
/>
<Input.TextArea value={note} onChange={(event) => setNote(event.target.value)} maxLength={2000} rows={4} placeholder="内部备注(用户看不到)" />
</Space>
) : null}
</Modal>
</>
);
}