98cea13d51
- 重命名:菜单与页面标题「反馈工单→用户反馈」「上报审核→低价审核」 - 反馈页「加群二维码」卡挪到系统配置页,新增「反馈二维码」tab - 修复反馈四态统计恒为 0:对接后端新增的 /admin/api/feedbacks/summary - 两页表格增强:用户列拆为 用户ID/手机号/用户昵称;新增 提交版本号、机型/OS版本 列; 点手机号弹抽屉看该用户全部记录,抽屉顶部汇总 提交/上报总计 + 奖励总计 - 低价审核:提交时间列可排序、审核结果与操作拆两列、顶部统计改卡片式、 Trace 列对齐比价记录页(有 trace_url 即可点) - 修复反馈时间显示快 8 小时:反馈时间为北京 wall-clock,改用 formatWallTime Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #28 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
395 lines
12 KiB
TypeScript
395 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import type { SorterResult } from 'antd/es/table/interface';
|
|
import {
|
|
Button,
|
|
Card,
|
|
DatePicker,
|
|
Image,
|
|
Input,
|
|
Select,
|
|
Space,
|
|
Statistic,
|
|
Table,
|
|
Tag,
|
|
Typography,
|
|
} from 'antd';
|
|
import type { Dayjs } from 'dayjs';
|
|
import { api } from '@/lib/api';
|
|
import { canDo } from '@/lib/auth';
|
|
import { formatWallTime } from '@/lib/format';
|
|
import { usePagedList } from '@/lib/usePagedList';
|
|
import type { Feedback, FeedbackSummary } from '@/lib/types';
|
|
import FeedbackHandleDrawer from './FeedbackHandleDrawer';
|
|
import UserRecordsDrawer from '@/components/UserRecordsDrawer';
|
|
|
|
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' },
|
|
};
|
|
|
|
const statusTag = (s: string) => {
|
|
const m = STATUS_META[s] ?? { label: s, color: 'default' };
|
|
return <Tag color={m.color}>{m.label}</Tag>;
|
|
};
|
|
|
|
// 审核结果展示(采纳=金币+批注 / 未采纳=原因 / 待审核=-),主表与「该用户全部反馈」抽屉共用
|
|
const renderReview = (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>;
|
|
};
|
|
|
|
// 机型 / OS版本(提交反馈时的端环境;改版前历史反馈无 → -)
|
|
const renderDeviceOs = (f: Feedback) => {
|
|
const os = [f.android_version ? `Android ${f.android_version}` : null, f.rom_name]
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
if (!f.device_model && !os) return <Text type="secondary">-</Text>;
|
|
return (
|
|
<Space direction="vertical" size={0}>
|
|
<Text>{f.device_model || '-'}</Text>
|
|
{os ? (
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
{os}
|
|
</Text>
|
|
) : null}
|
|
</Space>
|
|
);
|
|
};
|
|
|
|
// 点手机号弹出的「该用户全部反馈」抽屉列(精简版:不含操作/手机号,避免抽屉里再套抽屉)
|
|
const RECORD_COLUMNS: ColumnsType<Feedback> = [
|
|
{ title: '提交时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
|
|
{ title: '状态', dataIndex: 'status', width: 80, render: statusTag },
|
|
{
|
|
title: '内容',
|
|
dataIndex: 'content',
|
|
render: (v: string) =>
|
|
v ? (
|
|
<Paragraph
|
|
style={{ marginBottom: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
|
ellipsis={{ rows: 2, expandable: true, symbol: '展开' }}
|
|
>
|
|
{v}
|
|
</Paragraph>
|
|
) : (
|
|
<Text type="secondary">-</Text>
|
|
),
|
|
},
|
|
{ title: '审核结果', key: 'review', width: 160, render: (_: unknown, f: Feedback) => renderReview(f) },
|
|
];
|
|
|
|
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 [summary, setSummary] = useState<FeedbackSummary | null>(null);
|
|
const loadSummary = async () => {
|
|
try {
|
|
const { data } = await api.get<FeedbackSummary>('/admin/api/feedbacks/summary');
|
|
setSummary(data);
|
|
} catch {
|
|
/* 统计失败不阻塞列表 */
|
|
}
|
|
};
|
|
useEffect(() => {
|
|
loadSummary();
|
|
}, []);
|
|
|
|
// 审核/查看抽屉(采纳发金币 / 拒绝填原因 + 看该用户历史反馈)
|
|
const [drawerFb, setDrawerFb] = useState<Feedback | null>(null);
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const openDrawer = (f: Feedback) => {
|
|
setDrawerFb(f);
|
|
setDrawerOpen(true);
|
|
};
|
|
|
|
// 点手机号:看该用户全部反馈
|
|
const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
|
|
const openUserRecords = (f: Feedback) => setRecordsUser({ userId: f.user_id, phone: f.phone });
|
|
|
|
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: 'user_id',
|
|
width: 80,
|
|
render: (v: number) => <Text strong>#{v}</Text>,
|
|
},
|
|
{
|
|
title: '手机号',
|
|
dataIndex: 'phone',
|
|
width: 130,
|
|
render: (_: unknown, f: Feedback) =>
|
|
f.phone ? (
|
|
<a onClick={() => openUserRecords(f)}>{f.phone}</a>
|
|
) : (
|
|
<Text type="secondary">无手机号</Text>
|
|
),
|
|
},
|
|
{
|
|
title: '用户昵称',
|
|
dataIndex: 'nickname',
|
|
width: 120,
|
|
render: (v: string | null) => v || <Text type="secondary">无昵称</Text>,
|
|
},
|
|
{
|
|
title: '提交版本号',
|
|
dataIndex: 'app_version',
|
|
width: 100,
|
|
render: (v: string | null) => v || <Text type="secondary">-</Text>,
|
|
},
|
|
{
|
|
title: '机型 / OS版本',
|
|
key: 'device',
|
|
width: 150,
|
|
render: (_: unknown, f: Feedback) => renderDeviceOs(f),
|
|
},
|
|
{
|
|
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: statusTag },
|
|
{
|
|
title: '审核结果',
|
|
key: 'review',
|
|
width: 170,
|
|
render: (_: unknown, f: Feedback) => renderReview(f),
|
|
},
|
|
{
|
|
title: '提交时间',
|
|
dataIndex: 'created_at',
|
|
width: 170,
|
|
sorter: true,
|
|
sortOrder: sortOrderOf('created_at'),
|
|
render: (v: string) => formatWallTime(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>
|
|
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
|
|
<Card size="small" style={{ minWidth: 120 }}>
|
|
<Statistic title="待审核" value={summary?.pending ?? 0} valueStyle={{ color: '#fa8c16' }} />
|
|
</Card>
|
|
<Card size="small" style={{ minWidth: 120 }}>
|
|
<Statistic title="已采纳" value={summary?.adopted ?? 0} valueStyle={{ color: '#52c41a' }} />
|
|
</Card>
|
|
<Card size="small" style={{ minWidth: 120 }}>
|
|
<Statistic title="未采纳" value={summary?.rejected ?? 0} valueStyle={{ color: '#8c8c8c' }} />
|
|
</Card>
|
|
<Card size="small" style={{ minWidth: 120 }}>
|
|
<Statistic title="合计" value={summary?.total ?? 0} />
|
|
</Card>
|
|
</div>
|
|
<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}
|
|
scroll={{ x: 1610 }}
|
|
/>
|
|
|
|
<FeedbackHandleDrawer
|
|
feedback={drawerFb}
|
|
open={drawerOpen}
|
|
onClose={() => setDrawerOpen(false)}
|
|
onDone={() => { reload(); loadSummary(); }}
|
|
/>
|
|
|
|
<UserRecordsDrawer<Feedback>
|
|
open={recordsUser != null}
|
|
onClose={() => setRecordsUser(null)}
|
|
userId={recordsUser?.userId ?? null}
|
|
phone={recordsUser?.phone ?? null}
|
|
endpoint="/admin/api/feedbacks"
|
|
columns={RECORD_COLUMNS}
|
|
recordLabel="反馈"
|
|
countLabel="提交总计"
|
|
rewardLabel="提交奖励总计"
|
|
rewardOf={(f) => (f.status === 'adopted' ? (f.reward_coins ?? 0) : 0)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|