Files
shaguabijia-admin-web/src/app/(main)/feedbacks/page.tsx
T
zhuzihao 65d968585e 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>
2026-06-22 23:20:24 +08:00

275 lines
8.2 KiB
TypeScript

'use client';
import { useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import {
Button,
DatePicker,
Image,
Input,
Select,
Space,
Table,
Tag,
Typography,
} from 'antd';
import type { Dayjs } from 'dayjs';
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;
// 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。
const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE || '';
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 [status, setStatus] = useState<string | undefined>();
const [userId, setUserId] = useState('');
const [content, setContent] = useState('');
const [createdRange, setCreatedRange] = useState<[Dayjs, Dayjs] | null>(null);
const [applied, setApplied] = useState<Record<string, unknown>>({});
// 排序(服务端):点列头即时生效,与「查询」按钮分开
const [sortBy, setSortBy] = useState<SortField>('id');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<Feedback>('/admin/api/feedbacks', filters);
const canReview = canDo(['operator']);
// 审核/查看抽屉(采纳发金币 / 拒绝填原因 + 看该用户历史反馈)
const [drawerFb, setDrawerFb] = useState<Feedback | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const openDrawer = (f: Feedback) => {
setDrawerFb(f);
setDrawerOpen(true);
};
const search = () =>
setApplied({
status,
user_id: userId.trim() ? Number(userId.trim()) : undefined,
content: content.trim() || undefined,
created_from: createdRange?.[0] ? createdRange[0].startOf('day').toISOString() : undefined,
created_to: createdRange?.[1] ? createdRange[1].endOf('day').toISOString() : undefined,
});
const resetFilters = () => {
setStatus(undefined);
setUserId('');
setContent('');
setCreatedRange(null);
setSortBy('id');
setSortOrder('desc');
setApplied({});
};
const onTableChange = (
_pagination: unknown,
_filters: unknown,
sorter: SorterResult<Feedback> | SorterResult<Feedback>[],
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
if (s && s.order && s.field) {
setSortBy(s.field as SortField);
setSortOrder(s.order === 'ascend' ? 'asc' : 'desc');
} else {
setSortBy('id');
setSortOrder('desc');
}
};
const sortOrderOf = (field: SortField) =>
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
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: 340,
render: (v: string) =>
v ? (
<Paragraph
style={{ marginBottom: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
ellipsis={{ rows: 3, expandable: true, symbol: '展开', tooltip: true }}
>
{v}
</Paragraph>
) : (
<Text type="secondary">-</Text>
),
},
{
title: '截图',
dataIndex: 'images',
width: 160,
render: (imgs: string[] | null) =>
imgs && imgs.length ? (
<Image.PreviewGroup>
<Space size={4} wrap>
{imgs.map((src, i) => (
<Image
key={i}
src={mediaUrl(src)}
width={44}
height={44}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
))}
</Space>
</Image.PreviewGroup>
) : (
<Text type="secondary"></Text>
),
},
{
title: '状态',
dataIndex: 'status',
width: 90,
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: 170,
sorter: true,
sortOrder: sortOrderOf('created_at'),
render: (v: string) => formatUtcTime(v),
},
{
title: '操作',
key: 'op',
width: 90,
render: (_: unknown, f: Feedback) =>
canReview && isPending(f.status) ? (
<a onClick={() => openDrawer(f)}></a>
) : (
<a onClick={() => openDrawer(f)}></a>
),
},
];
return (
<div>
<h2></h2>
<FeedbackQrConfig />
<Space style={{ marginBottom: 16 }} wrap>
<Select
placeholder="状态"
value={status}
onChange={setStatus}
allowClear
style={{ width: 120 }}
options={[
{ value: 'pending', label: '审核中' },
{ value: 'adopted', label: '已采纳' },
{ value: 'rejected', label: '未采纳' },
]}
/>
<Input
placeholder="用户ID"
value={userId}
onChange={(e) => setUserId(e.target.value.replace(/\D/g, ''))}
onPressEnter={search}
allowClear
style={{ width: 120 }}
/>
<Input
placeholder="内容(模糊)"
value={content}
onChange={(e) => setContent(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 180 }}
/>
<RangePicker
placeholder={['提交起', '提交止']}
value={createdRange}
onChange={(v) => setCreatedRange(v as [Dayjs, Dayjs] | null)}
allowClear
/>
<Button type="primary" onClick={search}>
</Button>
<Button onClick={resetFilters}></Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 条反馈`,
onChange: onPageChange,
}}
onChange={onTableChange}
/>
<FeedbackHandleDrawer
feedback={drawerFb}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onDone={reload}
/>
</div>
);
}