feat(price-reports): 运营后台「上报审核」页
用户上报"某平台比我们算的最低价更便宜"+截图,运营人工审核: - price-reports/page.tsx: 列表+截图预览+通过(发1000金币)/拒绝(填理由),仿 withdraws - layout.tsx: 侧边栏菜单加「上报审核」(FlagOutlined) - lib/types.ts: PriceReport / PriceReportSummary DTO - .gitignore: 加 CLAUDE.md(本地维护不入库,同 android/app-server 约定) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,3 +7,6 @@ next-env.d.ts
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
data/
|
data/
|
||||||
.run-logs/
|
.run-logs/
|
||||||
|
|
||||||
|
# CLAUDE.md 本地维护、不入库(同 android/app-server 约定)
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation';
|
|||||||
import {
|
import {
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
FileSearchOutlined,
|
FileSearchOutlined,
|
||||||
|
FlagOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
MoneyCollectOutlined,
|
MoneyCollectOutlined,
|
||||||
@@ -22,6 +23,7 @@ const MENU = [
|
|||||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
|
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
|
||||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||||
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
|
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
|
||||||
|
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
|
||||||
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
|
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
|
||||||
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
|
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
|
||||||
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
|
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Image,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Space,
|
||||||
|
Statistic,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} 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 { useCursorList } from '@/lib/useCursorList';
|
||||||
|
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)}`);
|
||||||
|
|
||||||
|
// 截图是 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);
|
||||||
|
|
||||||
|
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 [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, nextCursor, loading, loadMore, reload } = useCursorList<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={false}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||||
|
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||||
|
{nextCursor ? '加载更多' : '没有更多了'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -198,3 +198,30 @@ export interface DashboardOverview {
|
|||||||
feedback: { new: number };
|
feedback: { new: number };
|
||||||
cps: { available: boolean; note: string };
|
cps: { available: boolean; note: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PriceReport {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
comparison_record_id: number | null;
|
||||||
|
store_name: string | null;
|
||||||
|
dish_summary: string | null;
|
||||||
|
original_platform_id: string | null;
|
||||||
|
original_platform_name: string | null;
|
||||||
|
original_price_cents: number | null;
|
||||||
|
reported_platform_id: string;
|
||||||
|
reported_platform_name: string;
|
||||||
|
reported_price_cents: number;
|
||||||
|
images: string[];
|
||||||
|
status: string; // pending / approved / rejected
|
||||||
|
reject_reason: string | null;
|
||||||
|
reward_coins: number | null;
|
||||||
|
reviewed_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceReportSummary {
|
||||||
|
pending: number;
|
||||||
|
approved: number;
|
||||||
|
rejected: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user