feat(admin-web): 反馈工单页加截图列 + 排序/筛选/批量标记处理
- 截图列:缩略图 + Image.PreviewGroup 预览,URL 走 NEXT_PUBLIC_MEDIA_BASE(本地 :8770) - 排序:ID/时间 列头服务端排序 - 筛选:状态 + 用户ID + 内容模糊 + 提交时间范围(草稿态 + 查询/重置) - 批量:勾选 new 态 → 批量标记处理(循环 /handle + Promise.allSettled) - 对齐用户页交互模式;依赖 app-server list_feedbacks 新增的 sort/filter 参数 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Key } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Button, Select, Space, Table, Tag, message } from 'antd';
|
||||
import type { SorterResult } from 'antd/es/table/interface';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
import { useCursorList } from '@/lib/useCursorList';
|
||||
import type { Feedback } from '@/lib/types';
|
||||
|
||||
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
|
||||
const { Text } = 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';
|
||||
|
||||
export default function FeedbacksPage() {
|
||||
// 筛选草稿:点「查询」才应用(避免输入即刷新)
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
||||
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 filterKey = JSON.stringify(filters);
|
||||
const { items, nextCursor, loading, loadMore, reload } = useCursorList<Feedback>(
|
||||
'/admin/api/feedbacks',
|
||||
filters,
|
||||
);
|
||||
|
||||
const canHandle = canDo(['operator']);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||
|
||||
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
|
||||
useEffect(() => {
|
||||
setSelectedRowKeys([]);
|
||||
}, [filterKey]);
|
||||
|
||||
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 handle = async (f: Feedback) => {
|
||||
try {
|
||||
@@ -29,18 +106,75 @@ export default function FeedbacksPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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 },
|
||||
{ title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') },
|
||||
{ title: '用户', dataIndex: 'user_id', width: 80 },
|
||||
{ title: '内容', dataIndex: 'content' },
|
||||
{ title: '联系方式', dataIndex: 'contact', width: 140 },
|
||||
{
|
||||
title: '截图',
|
||||
dataIndex: 'images',
|
||||
width: 180,
|
||||
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: 'contact', width: 140, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: string) => <Tag color={s === 'new' ? 'orange' : 'green'}>{s}</Tag>,
|
||||
},
|
||||
{ title: '时间', dataIndex: 'created_at', render: dt, width: 180 },
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
sorter: true,
|
||||
sortOrder: sortOrderOf('created_at'),
|
||||
render: (v: string) => formatUtcTime(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'op',
|
||||
@@ -57,23 +191,75 @@ export default function FeedbacksPage() {
|
||||
return (
|
||||
<div>
|
||||
<h2>反馈工单</h2>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
setFilters({ status: v });
|
||||
}}
|
||||
onChange={setStatus}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'new', label: '待处理' },
|
||||
{ value: 'handled', 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={false} />
|
||||
|
||||
{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}
|
||||
pagination={false}
|
||||
onChange={onTableChange}
|
||||
/>
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||
{nextCursor ? '加载更多' : '没有更多了'}
|
||||
|
||||
Reference in New Issue
Block a user