Files
shaguabijia-admin-web/src/app/(main)/price-reports/page.tsx
T
zzhyyyyy e9e7cda92a Merge origin/main into feat/feedback-qr-and-withdraw-reward
解决冲突(main 已把列表页迁到 usePagedList,本侧净增 FeedbackQrConfig / UserRewardPanel / lib/media):
- feedbacks:采用 main 演进后的页面(usePagedList/筛选/Typography 富渲染),仅保留本侧 <FeedbackQrConfig />;
  mediaUrl 用 main 的本地 const(去掉本侧重复 import 避免重声明)。
- price-reports:保留 mediaUrl(lib/media)+ main 的 usePagedList,去掉未用的 useCursorList。
- users:本侧 setForceOnboarding 与 main 的批量封禁/解封/调试链接函数并存;setForceOnboarding 改用
  App.useApp() 的 modal(全文已无静态 Modal 导入)。两功能的列按钮/批量按钮均已在自动合并区接好。
- withdraws:分页用 main 的 usePagedList 机制 + 本侧 WithdrawListItem 类型(与列定义/后端富化返回 total 一致);
  详情抽屉保留本侧 UserRewardPanel,丢掉 main 那两个孤儿列(cashColumns/recentWithdrawColumns,合并后无引用);
  清理随之未用的 formatWallTime 导入。

tsc --noEmit:本侧 4 文件 0 错误(仅余 main 既有 cps 页缺 @ant-design/plots 本地依赖,CI 全新安装可解)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 00:12:42 +08:00

326 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState } from 'react';
import {
App,
Button,
Card,
Image,
Input,
Modal,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { mediaUrl } from '@/lib/media';
import { usePagedList } from '@/lib/usePagedList';
import type { PriceReport, PriceReportSummary } from '@/lib/types';
const { Text } = Typography;
// 后端 created_at/reviewed_at 存的是北京 wall-clock(naive,同 savings/comparison),
// 直接本地解析、不加 Z(否则会差 8h)。
const dt = (v: string | null) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-');
const yuan = (c: number | null) => (c == null ? '-' : `¥${(c / 100).toFixed(2)}`);
// 截图 URL 解析见 @/lib/media(dev 指向 :8770,生产走 nginx /media 同域反代)。
const STATUS_COLOR: Record<string, string> = {
pending: 'gold',
approved: 'green',
rejected: 'default',
};
const STATUS_LABEL: Record<string, string> = {
pending: '待审核',
approved: '已通过',
rejected: '已拒绝',
};
const STATUS_TABS = [
{ key: 'pending', label: '待审核' },
{ key: 'approved', label: '已通过' },
{ key: 'rejected', label: '已拒绝' },
{ key: 'all', label: '全部' },
];
const REJECT_TEMPLATES = ['截图不清晰', '商品不匹配', '价格不属实', '平台不支持', '重复上报', '其他原因'];
function statusTag(status: string) {
return <Tag color={STATUS_COLOR[status]}>{STATUS_LABEL[status] || status}</Tag>;
}
export default function PriceReportsPage() {
const { message, modal } = App.useApp();
const [activeStatus, setActiveStatus] = useState('pending');
const [summary, setSummary] = useState<PriceReportSummary | null>(null);
const [rejecting, setRejecting] = useState<PriceReport | null>(null);
const [rejectReason, setRejectReason] = useState('');
const filters: Record<string, unknown> = {};
if (activeStatus !== 'all') filters.status = activeStatus;
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<PriceReport>('/admin/api/price-reports', filters);
const canReview = canDo(['operator']);
const loadSummary = async () => {
try {
const { data } = await api.get<PriceReportSummary>('/admin/api/price-reports/summary');
setSummary(data);
} catch {
/* 统计失败不阻塞列表 */
}
};
useEffect(() => {
loadSummary();
}, []);
const refreshAfterChange = () => {
reload();
loadSummary();
};
const approve = (r: PriceReport) => {
modal.confirm({
title: '确认通过该上报?',
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
content: (
<div>
<div> #{r.user_id}</div>
<div>: {r.store_name || '-'}</div>
<div>
: {yuan(r.reported_price_cents)}{r.reported_platform_name}
</div>
<div style={{ marginTop: 8, color: '#8c8c8c' }}>
1000
</div>
</div>
),
okText: '通过并发金币',
onOk: async () => {
try {
await api.post(`/admin/api/price-reports/${r.id}/approve`);
message.success('已通过,已发放 1000 金币');
refreshAfterChange();
} catch (e) {
message.error(errMsg(e));
}
},
});
};
const confirmReject = async () => {
if (!rejecting) return;
const reason = rejectReason.trim();
if (!reason) {
message.warning('请填写拒绝理由');
return;
}
try {
await api.post(`/admin/api/price-reports/${rejecting.id}/reject`, { reason });
message.success('已拒绝');
setRejecting(null);
setRejectReason('');
refreshAfterChange();
} catch (e) {
message.error(errMsg(e));
}
};
const columns: ColumnsType<PriceReport> = [
{
title: '用户',
dataIndex: 'user_id',
width: 90,
render: (v: number) => <Text strong>#{v}</Text>,
},
{
title: '门店 / 菜品',
key: 'store',
width: 200,
render: (_: unknown, r: PriceReport) => (
<Space direction="vertical" size={0}>
<Text>{r.store_name || '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.dish_summary || '-'}
</Text>
</Space>
),
},
{
title: '原最低价 → 上报价',
key: 'price',
width: 220,
render: (_: unknown, r: PriceReport) => (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.original_platform_name || '原'}: {yuan(r.original_price_cents)}
</Text>
<Text strong style={{ color: '#fa541c' }}>
{r.reported_platform_name}: {yuan(r.reported_price_cents)}
</Text>
</Space>
),
},
{
title: '截图证明',
dataIndex: 'images',
width: 180,
render: (imgs: string[]) =>
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: '提交时间',
dataIndex: 'created_at',
width: 150,
render: (v: string) => dt(v),
},
{
title: '操作 / 结果',
key: 'op',
fixed: 'right',
width: 200,
render: (_: unknown, r: PriceReport) => {
if (r.status === 'pending') {
if (!canReview) return <Text type="secondary"></Text>;
return (
<Space>
<Button
size="small"
type="link"
icon={<CheckCircleOutlined />}
onClick={() => approve(r)}
>
</Button>
<Button
size="small"
type="link"
danger
icon={<CloseCircleOutlined />}
onClick={() => {
setRejecting(r);
setRejectReason('');
}}
>
</Button>
</Space>
);
}
if (r.status === 'approved') {
return <Text type="success"> {r.reward_coins ?? 0} </Text>;
}
if (r.status === 'rejected') {
return <Text type="secondary">: {r.reject_reason || '-'}</Text>;
}
return <Text type="secondary">-</Text>;
},
},
];
return (
<div>
<Space direction="vertical" style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Text type="secondary">
+ 1000 app
</Text>
</Space>
{summary && (
<Space size="large" style={{ marginBottom: 16 }}>
<Statistic title="待审核" value={summary.pending} valueStyle={{ color: '#faad14' }} />
<Statistic title="已通过" value={summary.approved} valueStyle={{ color: '#52c41a' }} />
<Statistic title="已拒绝" value={summary.rejected} />
<Statistic title="合计" value={summary.total} />
</Space>
)}
<Card>
<Tabs
activeKey={activeStatus}
onChange={setActiveStatus}
items={STATUS_TABS.map((t) => ({ key: t.key, label: t.label }))}
/>
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: onPageChange,
}}
scroll={{ x: 1100 }}
/>
</Card>
<Modal
title="拒绝上报"
open={!!rejecting}
okText="确认拒绝"
okButtonProps={{ danger: true, icon: <CloseCircleOutlined /> }}
onOk={confirmReject}
onCancel={() => {
setRejecting(null);
setRejectReason('');
}}
>
{rejecting && (
<Space direction="vertical" style={{ width: '100%' }}>
<Text>
#{rejecting.user_id} · {rejecting.store_name || '-'}
</Text>
<Space wrap>
{REJECT_TEMPLATES.map((tpl) => (
<Button size="small" key={tpl} onClick={() => setRejectReason(tpl)}>
{tpl}
</Button>
))}
</Space>
<Input.TextArea
rows={4}
maxLength={200}
value={rejectReason}
placeholder="请输入拒绝理由,用户端记录页会看到"
onChange={(e) => setRejectReason(e.target.value)}
/>
</Space>
)}
</Modal>
</div>
);
}