feat(feedback): 反馈工单接入审核采纳/拒绝 + 历史反馈 (#16)
对接后端 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) <noreply@anthropic.com> --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #16 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
This commit was merged in pull request #16.
This commit is contained in:
@@ -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<string, { label: string; color: string }> = {
|
||||
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<string | undefined>();
|
||||
const [userId, setUserId] = useState('');
|
||||
@@ -46,17 +53,18 @@ export default function FeedbacksPage() {
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
|
||||
const filterKey = JSON.stringify(filters);
|
||||
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
|
||||
usePagedList<Feedback>('/admin/api/feedbacks', filters);
|
||||
|
||||
const canHandle = canDo(['operator']);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||
const canReview = canDo(['operator']);
|
||||
|
||||
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
|
||||
useEffect(() => {
|
||||
setSelectedRowKeys([]);
|
||||
}, [filterKey]);
|
||||
// 审核/查看抽屉(采纳发金币 / 拒绝填原因 + 看该用户历史反馈)
|
||||
const [drawerFb, setDrawerFb] = useState<Feedback | null>(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<Feedback> = [
|
||||
{ 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 ? (
|
||||
<Paragraph
|
||||
@@ -154,7 +125,7 @@ export default function FeedbacksPage() {
|
||||
{
|
||||
title: '截图',
|
||||
dataIndex: 'images',
|
||||
width: 180,
|
||||
width: 160,
|
||||
render: (imgs: string[] | null) =>
|
||||
imgs && imgs.length ? (
|
||||
<Image.PreviewGroup>
|
||||
@@ -174,17 +145,46 @@ export default function FeedbacksPage() {
|
||||
<Text type="secondary">无</Text>
|
||||
),
|
||||
},
|
||||
{ title: '联系方式', dataIndex: 'contact', width: 140, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: string) => <Tag color={s === 'new' ? 'orange' : 'green'}>{s}</Tag>,
|
||||
render: (s: string) => {
|
||||
const m = STATUS_META[s] ?? { label: s, color: 'default' };
|
||||
return <Tag color={m.color}>{m.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '审核结果',
|
||||
key: 'review',
|
||||
width: 170,
|
||||
render: (_: unknown, f: Feedback) => {
|
||||
if (f.status === 'adopted') {
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color="gold">+{f.reward_coins ?? 0} 金币</Tag>
|
||||
{f.review_note ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
||||
{f.review_note}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
if (f.status === 'rejected') {
|
||||
return (
|
||||
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
||||
未采纳:{f.reject_reason || '-'}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return <Text type="secondary">-</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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' ? (
|
||||
<a onClick={() => handle(f)}>标记处理</a>
|
||||
canReview && isPending(f.status) ? (
|
||||
<a onClick={() => openDrawer(f)}>审核</a>
|
||||
) : (
|
||||
<span style={{ color: '#ccc' }}>-</span>
|
||||
<a onClick={() => openDrawer(f)}>查看</a>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -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: '未采纳' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
@@ -246,29 +247,8 @@ export default function FeedbacksPage() {
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
</Space>
|
||||
|
||||
{canHandle && selectedNew.length > 0 && (
|
||||
<Space style={{ marginBottom: 12, width: '100%' }} wrap>
|
||||
<span style={{ color: '#888' }}>已选 {selectedNew.length} 条待处理</span>
|
||||
<Button type="primary" onClick={bulkHandle}>
|
||||
批量标记处理
|
||||
</Button>
|
||||
<Button type="link" onClick={() => setSelectedRowKeys([])}>
|
||||
清空选择
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
rowSelection={
|
||||
canHandle
|
||||
? {
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
getCheckboxProps: (f) => ({ disabled: f.status !== 'new' }),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
@@ -282,6 +262,13 @@ export default function FeedbacksPage() {
|
||||
}}
|
||||
onChange={onTableChange}
|
||||
/>
|
||||
|
||||
<FeedbackHandleDrawer
|
||||
feedback={drawerFb}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onDone={reload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user