From 65d968585e16555cf40ba8b052d0b65b7678c166 Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Mon, 22 Jun 2026 23:20:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(feedback):=20=E5=8F=8D=E9=A6=88=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E6=8E=A5=E5=85=A5=E5=AE=A1=E6=A0=B8=E9=87=87=E7=BA=B3?= =?UTF-8?q?/=E6=8B=92=E7=BB=9D=20+=20=E5=8E=86=E5=8F=B2=E5=8F=8D=E9=A6=88?= =?UTF-8?q?=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对接后端 approve/reject 接口,admin 反馈工单从单一「处理」改为审核双动作: - FeedbackHandleDrawer: Segmented 采纳/拒绝 —— 采纳(发金币 1~10000 + 采纳备注)、 拒绝(填用户端可见原因 + 内部备注);抽屉内联展示该用户历史反馈辅助审核 - 列表页: 状态筛选改 审核中/已采纳/未采纳、新增「审核结果」列、操作改 审核/查看,移除旧批量标记 - types.ts: Feedback 字段对齐后端(reject_reason/reward_coins/review_note/reviewed_by_admin_id/reviewed_at) Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/16 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- .../(main)/feedbacks/FeedbackHandleDrawer.tsx | 325 ++++++++++++++++++ src/app/(main)/feedbacks/page.tsx | 149 ++++---- src/lib/types.ts | 8 +- 3 files changed, 400 insertions(+), 82 deletions(-) create mode 100644 src/app/(main)/feedbacks/FeedbackHandleDrawer.tsx diff --git a/src/app/(main)/feedbacks/FeedbackHandleDrawer.tsx b/src/app/(main)/feedbacks/FeedbackHandleDrawer.tsx new file mode 100644 index 0000000..7761b0b --- /dev/null +++ b/src/app/(main)/feedbacks/FeedbackHandleDrawer.tsx @@ -0,0 +1,325 @@ +'use client'; + +// 反馈审核抽屉:展示本条反馈 + 该用户历史反馈(就近参考),待审核态可采纳(发金币)或拒绝(填用户可见原因)。 +// 已审核态只读回显。对接同事 PR#66 接口:POST .../approve、POST .../reject(.../handle 已废弃)。 +import { useEffect, useState } from 'react'; +import { + App, + Button, + Drawer, + Form, + Image, + Input, + InputNumber, + Segmented, + Space, + Spin, + Tag, + Typography, +} from 'antd'; +import { api, errMsg } from '@/lib/api'; +import { canDo } from '@/lib/auth'; +import { formatUtcTime } from '@/lib/format'; +import { mediaUrl } from '@/lib/media'; +import type { CursorPage, Feedback } from '@/lib/types'; + +const { Text, Paragraph } = Typography; + +const REWARD_MAX = 10000; // 对齐后端 FEEDBACK_REWARD_MAX_COINS + +interface Props { + feedback: Feedback | null; + open: boolean; + onClose: () => void; + onDone: () => void; // 审核成功后刷新列表 +} + +// 待审核(同事后端初始态 pending;兼容历史 new 数据) +const isPending = (s: string) => s === 'pending' || s === 'new'; + +const STATUS_META: Record = { + pending: { label: '审核中', color: 'orange' }, + new: { label: '待审核', color: 'orange' }, + adopted: { label: '已采纳', color: 'green' }, + rejected: { label: '未采纳', color: 'red' }, +}; + +const statusTag = (s: string) => { + const m = STATUS_META[s] ?? { label: s, color: 'default' }; + return {m.label}; +}; + +function FeedbackImages({ images }: { images: string[] | null }) { + if (!images || !images.length) return 无截图; + return ( + + + {images.map((src, i) => ( + + ))} + + + ); +} + +export default function FeedbackHandleDrawer({ feedback, open, onClose, onDone }: Props) { + const { message } = App.useApp(); + const [form] = Form.useForm(); + const [action, setAction] = useState<'approve' | 'reject'>('approve'); + const [submitting, setSubmitting] = useState(false); + const [history, setHistory] = useState([]); + const [loadingHistory, setLoadingHistory] = useState(false); + + const editable = feedback != null && isPending(feedback.status) && canDo(['operator']); + + // 打开时:重置表单 + 拉该用户全部历史反馈(含本条,渲染时滤掉本条) + useEffect(() => { + if (!open || !feedback) return; + setAction('approve'); + form.resetFields(); + let alive = true; + setLoadingHistory(true); + api + .get>('/admin/api/feedbacks', { + params: { user_id: feedback.user_id, limit: 50, sort_by: 'id', sort_order: 'desc' }, + }) + .then((r) => { + if (alive) setHistory(r.data.items); + }) + .catch(() => { + if (alive) setHistory([]); + }) + .finally(() => { + if (alive) setLoadingHistory(false); + }); + return () => { + alive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, feedback?.id]); + + const submit = async () => { + if (!feedback) return; + const v = await form.validateFields(); + setSubmitting(true); + try { + if (action === 'approve') { + await api.post(`/admin/api/feedbacks/${feedback.id}/approve`, { + reward_coins: v.reward_coins, + note: v.note?.trim() || null, + }); + message.success(`已采纳,发放 ${v.reward_coins} 金币`); + } else { + await api.post(`/admin/api/feedbacks/${feedback.id}/reject`, { + reason: v.reason.trim(), + note: v.note?.trim() || null, + }); + message.success('已拒绝'); + } + onClose(); + onDone(); + } catch (e) { + message.error(errMsg(e)); + } finally { + setSubmitting(false); + } + }; + + const others = feedback ? history.filter((h) => h.id !== feedback.id) : []; + + return ( + + {action === 'approve' ? '采纳并发金币' : '确认拒绝'} + + ) : null + } + > + {feedback && ( + + {/* 本条反馈 */} +
+ + 用户 {feedback.user_id} · {formatUtcTime(feedback.created_at)} + + + {feedback.content || '-'} + + +
+ + {/* 已审核(或无权限):只读回显 */} + {!editable && ( +
+
+ 状态:{statusTag(feedback.status)} + {feedback.reviewed_at ? ( + · {formatUtcTime(feedback.reviewed_at)} + ) : null} +
+ {feedback.status === 'adopted' && ( +
+ 奖励金币:{feedback.reward_coins ?? 0} +
+ )} + {feedback.status === 'rejected' && ( +
+ 未采纳原因(用户可见): + {feedback.reject_reason || (无)} +
+ )} +
+ 审核备注:{feedback.review_note || (无)} +
+ {isPending(feedback.status) && ( +
+ 仅 operator / super_admin 可审核 +
+ )} +
+ )} + + {/* 待审核:采纳 / 拒绝表单 */} + {editable && ( +
+ setAction(v as 'approve' | 'reject')} + options={[ + { label: '采纳(发金币)', value: 'approve' }, + { label: '拒绝', value: 'reject' }, + ]} + style={{ marginBottom: 16 }} + /> + {/* preserve=false:切换采纳/拒绝时卸载的字段从 store 移除,validateFields 不误校验另一侧 */} +
+ {action === 'approve' ? ( + <> + + + + + + + + ) : ( + <> + + + + + + + + )} +
+
+ )} + + {/* 该用户历史反馈 */} +
+ 该用户历史反馈{others.length ? `(${others.length})` : ''} + {loadingHistory ? ( + + ) : others.length === 0 ? ( +
+ 该用户暂无其它反馈 +
+ ) : ( + + {others.map((h) => ( +
+
+ + #{h.id} · {formatUtcTime(h.created_at)} + + {statusTag(h.status)} +
+ + {h.content || '-'} + + {h.status === 'adopted' && ( + + 采纳 · 奖励 {h.reward_coins ?? 0} 金币 + {h.review_note ? ` · ${h.review_note}` : ''} + + )} + {h.status === 'rejected' && ( + + 未采纳:{h.reject_reason || '-'} + + )} +
+ ))} +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/app/(main)/feedbacks/page.tsx b/src/app/(main)/feedbacks/page.tsx index e7462f1..fe4d3ad 100644 --- a/src/app/(main)/feedbacks/page.tsx +++ b/src/app/(main)/feedbacks/page.tsx @@ -1,11 +1,9 @@ 'use client'; -import { useEffect, useState } from 'react'; -import type { Key } from 'react'; +import { useState } from 'react'; import type { ColumnsType } from 'antd/es/table'; import type { SorterResult } from 'antd/es/table/interface'; import { - App, Button, DatePicker, Image, @@ -17,12 +15,12 @@ import { Typography, } from 'antd'; import type { Dayjs } from 'dayjs'; -import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { formatUtcTime } from '@/lib/format'; import { usePagedList } from '@/lib/usePagedList'; import type { Feedback } from '@/lib/types'; import FeedbackQrConfig from './FeedbackQrConfig'; +import FeedbackHandleDrawer from './FeedbackHandleDrawer'; const { Text, Paragraph } = Typography; const { RangePicker } = DatePicker; @@ -33,8 +31,17 @@ const mediaUrl = (p: string) => (p.startsWith('http') ? p : MEDIA_BASE + p); type SortField = 'id' | 'created_at'; +// 待审核(同事后端初始态 pending;兼容历史 new 数据) +const isPending = (s: string) => s === 'pending' || s === 'new'; + +const STATUS_META: Record = { + pending: { label: '审核中', color: 'orange' }, + new: { label: '待审核', color: 'orange' }, + adopted: { label: '已采纳', color: 'green' }, + rejected: { label: '未采纳', color: 'red' }, +}; + export default function FeedbacksPage() { - const { message, modal } = App.useApp(); // 筛选草稿:点「查询」才应用(避免输入即刷新) const [status, setStatus] = useState(); const [userId, setUserId] = useState(''); @@ -46,17 +53,18 @@ export default function FeedbacksPage() { const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); const filters: Record = { ...applied, sort_by: sortBy, sort_order: sortOrder }; - const filterKey = JSON.stringify(filters); const { items, total, page, pageSize, loading, onChange: onPageChange, reload } = usePagedList('/admin/api/feedbacks', filters); - const canHandle = canDo(['operator']); - const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const canReview = canDo(['operator']); - // 筛选/排序变化后清空已选(避免选中项跨筛选错位) - useEffect(() => { - setSelectedRowKeys([]); - }, [filterKey]); + // 审核/查看抽屉(采纳发金币 / 拒绝填原因 + 看该用户历史反馈) + const [drawerFb, setDrawerFb] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const openDrawer = (f: Feedback) => { + setDrawerFb(f); + setDrawerOpen(true); + }; const search = () => setApplied({ @@ -95,50 +103,13 @@ export default function FeedbacksPage() { const sortOrderOf = (field: SortField) => sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null; - const handle = async (f: Feedback) => { - try { - await api.post(`/admin/api/feedbacks/${f.id}/handle`); - message.success('已标记处理'); - reload(); - } catch (e) { - message.error(errMsg(e)); - } - }; - - const selectedNew = items.filter( - (f) => selectedRowKeys.includes(f.id) && f.status === 'new', - ); - - // 批量:循环调现有逐条 /handle 接口(各自已带审计),Promise.allSettled 汇总成败,不新增 bulk 端点 - const bulkHandle = () => { - if (!selectedNew.length) { - message.warning('没有可处理的选中项(仅待处理 new 态生效)'); - return; - } - modal.confirm({ - title: `确认批量标记处理 ${selectedNew.length} 条反馈?`, - content: '仅对选中的待处理(new)反馈生效', - onOk: async () => { - const results = await Promise.allSettled( - selectedNew.map((f) => api.post(`/admin/api/feedbacks/${f.id}/handle`)), - ); - const ok = results.filter((r) => r.status === 'fulfilled').length; - const fail = results.length - ok; - if (fail) message.warning(`批量标记处理完成:成功 ${ok}/${results.length},失败 ${fail}`); - else message.success(`批量标记处理完成:${ok} 条`); - setSelectedRowKeys([]); - reload(); - }, - }); - }; - const columns: ColumnsType = [ { title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') }, { title: '用户', dataIndex: 'user_id', width: 80 }, { title: '内容', dataIndex: 'content', - width: 360, + width: 340, render: (v: string) => v ? ( imgs && imgs.length ? ( @@ -174,17 +145,46 @@ export default function FeedbacksPage() { ), }, - { title: '联系方式', dataIndex: 'contact', width: 140, render: (v: string) => v || '-' }, { title: '状态', dataIndex: 'status', width: 90, - render: (s: string) => {s}, + render: (s: string) => { + const m = STATUS_META[s] ?? { label: s, color: 'default' }; + return {m.label}; + }, + }, + { + title: '审核结果', + key: 'review', + width: 170, + render: (_: unknown, f: Feedback) => { + if (f.status === 'adopted') { + return ( + + +{f.reward_coins ?? 0} 金币 + {f.review_note ? ( + + {f.review_note} + + ) : null} + + ); + } + if (f.status === 'rejected') { + return ( + + 未采纳:{f.reject_reason || '-'} + + ); + } + return -; + }, }, { title: '时间', dataIndex: 'created_at', - width: 180, + width: 170, sorter: true, sortOrder: sortOrderOf('created_at'), render: (v: string) => formatUtcTime(v), @@ -192,12 +192,12 @@ export default function FeedbacksPage() { { title: '操作', key: 'op', - width: 100, + width: 90, render: (_: unknown, f: Feedback) => - canHandle && f.status === 'new' ? ( - handle(f)}>标记处理 + canReview && isPending(f.status) ? ( + openDrawer(f)}>审核 ) : ( - - + openDrawer(f)}>查看 ), }, ]; @@ -214,8 +214,9 @@ export default function FeedbacksPage() { allowClear style={{ width: 120 }} options={[ - { value: 'new', label: '待处理' }, - { value: 'handled', label: '已处理' }, + { value: 'pending', label: '审核中' }, + { value: 'adopted', label: '已采纳' }, + { value: 'rejected', label: '未采纳' }, ]} /> 重置 - {canHandle && selectedNew.length > 0 && ( - - 已选 {selectedNew.length} 条待处理 - - - - )} - ({ disabled: f.status !== 'new' }), - } - : undefined - } columns={columns} dataSource={items} loading={loading} @@ -282,6 +262,13 @@ export default function FeedbacksPage() { }} onChange={onTableChange} /> + + setDrawerOpen(false)} + onDone={reload} + /> ); } diff --git a/src/lib/types.ts b/src/lib/types.ts index febd7c4..53ccb0c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -206,7 +206,13 @@ export interface Feedback { content: string; contact: string; images: string[] | null; - status: string; // new / handled + // pending(审核中) / adopted(已采纳) / rejected(未采纳);历史数据可能为 new + status: string; + reject_reason: string | null; // 未采纳原因(用户端可见) + reward_coins: number | null; // 采纳后发放金币 + review_note: string | null; // 审核批注(采纳要点 / 内部备注) + reviewed_by_admin_id: number | null; + reviewed_at: string | null; created_at: string; }