diff --git a/.gitignore b/.gitignore index c75d099..b71d202 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ next-env.d.ts .DS_Store data/ .run-logs/ + +# CLAUDE.md 本地维护、不入库(同 android/app-server 约定) +CLAUDE.md diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index a7d4381..f888089 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation'; import { DashboardOutlined, FileSearchOutlined, + FlagOutlined, LogoutOutlined, MessageOutlined, MoneyCollectOutlined, @@ -22,6 +23,7 @@ const MENU = [ { key: '/dashboard', icon: , label: '数据大盘' }, { key: '/users', icon: , label: '用户管理' }, { key: '/withdraws', icon: , label: '提现管理' }, + { key: '/price-reports', icon: , label: '上报审核' }, { key: '/feedbacks', icon: , label: '反馈工单' }, { key: '/config', icon: , label: '系统配置' }, { key: '/admins', icon: , label: '管理员', superOnly: true }, diff --git a/src/app/(main)/price-reports/page.tsx b/src/app/(main)/price-reports/page.tsx new file mode 100644 index 0000000..d59bf6c --- /dev/null +++ b/src/app/(main)/price-reports/page.tsx @@ -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 = { + pending: 'gold', + approved: 'green', + rejected: 'default', +}; +const STATUS_LABEL: Record = { + 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 {STATUS_LABEL[status] || status}; +} + +export default function PriceReportsPage() { + const [activeStatus, setActiveStatus] = useState('pending'); + const [summary, setSummary] = useState(null); + const [rejecting, setRejecting] = useState(null); + const [rejectReason, setRejectReason] = useState(''); + + const filters: Record = {}; + if (activeStatus !== 'all') filters.status = activeStatus; + + const { items, nextCursor, loading, loadMore, reload } = useCursorList( + '/admin/api/price-reports', + filters, + ); + + const canReview = canDo(['operator']); + + const loadSummary = async () => { + try { + const { data } = await api.get('/admin/api/price-reports/summary'); + setSummary(data); + } catch { + /* 统计失败不阻塞列表 */ + } + }; + useEffect(() => { + loadSummary(); + }, []); + + const refreshAfterChange = () => { + reload(); + loadSummary(); + }; + + const approve = (r: PriceReport) => { + Modal.confirm({ + title: '确认通过该上报?', + icon: , + content: ( +
+
用户 #{r.user_id}
+
门店: {r.store_name || '-'}
+
+ 上报价: {yuan(r.reported_price_cents)}({r.reported_platform_name}) +
+
+ 通过后给用户发放 1000 金币奖励,请先确认截图属实。 +
+
+ ), + 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 = [ + { + title: '用户', + dataIndex: 'user_id', + width: 90, + render: (v: number) => #{v}, + }, + { + title: '门店 / 菜品', + key: 'store', + width: 200, + render: (_: unknown, r: PriceReport) => ( + + {r.store_name || '-'} + + {r.dish_summary || '-'} + + + ), + }, + { + title: '原最低价 → 上报价', + key: 'price', + width: 220, + render: (_: unknown, r: PriceReport) => ( + + + {r.original_platform_name || '原'}: {yuan(r.original_price_cents)} + + + {r.reported_platform_name}: {yuan(r.reported_price_cents)} + + + ), + }, + { + title: '截图证明', + dataIndex: 'images', + width: 180, + render: (imgs: string[]) => + imgs && imgs.length ? ( + + + {imgs.map((src, i) => ( + + ))} + + + ) : ( + + ), + }, + { 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 无审核权限; + return ( + + + + + ); + } + if (r.status === 'approved') { + return 已发 {r.reward_coins ?? 0} 金币; + } + if (r.status === 'rejected') { + return 拒绝: {r.reject_reason || '-'}; + } + return -; + }, + }, + ]; + + return ( +
+ + + 上报审核 + + + 用户上报「某平台更低价」+ 截图,人工核实后通过发放 1000 金币奖励、拒绝需填理由。用户在 app + 内轮询自动看到结果。 + + + + {summary && ( + + + + + + + )} + + + ({ key: t.key, label: t.label }))} + /> + +
+ +
+ + + }} + onOk={confirmReject} + onCancel={() => { + setRejecting(null); + setRejectReason(''); + }} + > + {rejecting && ( + + + 用户 #{rejecting.user_id} · {rejecting.store_name || '-'} + + + {REJECT_TEMPLATES.map((tpl) => ( + + ))} + + setRejectReason(e.target.value)} + /> + + )} + + + ); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 6ff6cd0..ae2dd6a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -198,3 +198,30 @@ export interface DashboardOverview { feedback: { new: number }; 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; +}