Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52c5830127 | |||
| 67fc466778 | |||
| 65d968585e | |||
| bd0bacb836 | |||
| 12cc6f7aa6 | |||
| 36d00a9d68 | |||
| 98417ff8c6 | |||
| b7957e58c0 | |||
| 5ae87fc11e | |||
| f4043f2abf | |||
| 472912560c | |||
| 9afb213b50 | |||
| 1a6e590a42 | |||
| cef5ea0140 | |||
| b1923c3133 | |||
| ecbce54e70 | |||
| 654aca4c40 | |||
| a393b5c0e3 | |||
| 9134ea9255 | |||
| f82f511b84 | |||
| 05a1d06e61 | |||
| 94d550db9b | |||
| 2eff4a71be | |||
| c66d90a6e1 | |||
| 1717ee8b6a | |||
| 8c7e2b0220 | |||
| 6766fe8c10 | |||
| 3c757aa838 | |||
| 97cdc07c4a | |||
| cac9a38192 | |||
| 45b9c7140d |
+8
-2
@@ -1,3 +1,9 @@
|
||||
# 生产:前端与 admin API 同域(admin.shaguabijia.com),nginx 把 /admin/api 反代到后端 8771。
|
||||
# 留空 = axios 用相对路径,请求落到当前域名,由 nginx 转发。
|
||||
# 生产:前端与 admin API 同域(admin-web.shaguabijia.com),nginx 把 /admin/api 反代到后端 8771。
|
||||
# 留空 = axios 用相对路径,请求落到当前域名,由 nginx 转发。(已验证线上 /admin/api 走通、操作 200)
|
||||
NEXT_PUBLIC_API_BASE=
|
||||
|
||||
# 媒体(反馈二维码 / 上报截图 / 头像)由 App 后端托管在 app-api.shaguabijia.com/media,【不在 admin 域】。
|
||||
# 必须指到 App 后端的公开域名,否则 admin 里所有 /media 图片都会 404(被拼到 admin 域而非 App 域)。
|
||||
# 都是 https → 无混合内容;<img> 跨域加载不受 CORS 限制,无需后端配 CORS。
|
||||
# ⚠️ NEXT_PUBLIC_* 是编译期常量:改这里后必须重新 `npm run build` 再部署,前端才会带上新值。
|
||||
NEXT_PUBLIC_MEDIA_BASE=https://app-api.shaguabijia.com
|
||||
|
||||
@@ -7,3 +7,6 @@ next-env.d.ts
|
||||
.DS_Store
|
||||
data/
|
||||
.run-logs/
|
||||
|
||||
# CLAUDE.md 本地维护、不入库(同 android/app-server 约定)
|
||||
CLAUDE.md
|
||||
|
||||
@@ -5,10 +5,12 @@ Next.js 15 + Ant Design 5,对接 `shaguabijia-app-server` 的 admin API(`app/adm
|
||||
鉴权用独立 JWT)。本仓只是前端。
|
||||
|
||||
## 功能(P0)
|
||||
|
||||
数据大盘 / 用户管理(列表+360详情+封禁+调金币) / 提现管理(列表+重试+对账) /
|
||||
反馈工单 / 管理员账号(super) / 审计日志。按角色显示操作:finance=钱、operator=用户+反馈、super=全部。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
# 1. 起 admin 后端(在 shaguabijia-app-server 目录)
|
||||
conda activate price && uvicorn app.admin.main:admin_app --port 8771
|
||||
@@ -17,11 +19,13 @@ python scripts/create_admin.py --username admin --role super_admin
|
||||
# 3. 起前端
|
||||
npm install && npm run dev # → http://localhost:3001
|
||||
```
|
||||
|
||||
`.env.local` 已指向 `http://localhost:8771`。
|
||||
|
||||
## 生产部署(阿里云,域名 admin.shaguabijia.com)
|
||||
|
||||
### ① 后端 admin(在 app-server 机器,复用同一 codebase)
|
||||
|
||||
```bash
|
||||
scp deploy/shaguabijia-admin.service server:/etc/systemd/system/ # 文件在 app-server/deploy/
|
||||
ssh server "systemctl daemon-reload && systemctl enable --now shaguabijia-admin"
|
||||
@@ -29,6 +33,7 @@ ssh server "cd /opt/shaguabijia-app-server && .venv/bin/python scripts/create_ad
|
||||
```
|
||||
|
||||
### ② 前端
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
rsync -avz --exclude node_modules --exclude .next/cache ./ server:/opt/shaguabijia-admin-web/
|
||||
@@ -36,13 +41,16 @@ ssh server "cd /opt/shaguabijia-admin-web && npm install --omit=dev && npm run b
|
||||
```
|
||||
|
||||
### ③ nginx
|
||||
|
||||
```bash
|
||||
scp deploy/nginx/admin.shaguabijia.com.conf server:/etc/nginx/conf.d/
|
||||
ssh server "nginx -t && systemctl reload nginx"
|
||||
```
|
||||
|
||||
## ⚠️ 上线必查
|
||||
- [ ] app-server 的 `.env` 里 `ADMIN_JWT_SECRET` 改成 ≥32 字节强随机串(默认 `change-me-admin` 可伪造 token)
|
||||
- [ ] nginx 打开 IP 白名单(运营出口 IP)+ HTTPS 证书就位
|
||||
- [ ] `.env.production` 的 `NEXT_PUBLIC_API_BASE` 留空(同域,nginx 反代 /admin/api)
|
||||
- [ ] 首个 super_admin 已建、能登录
|
||||
|
||||
- app-server 的 `.env` 里 `ADMIN_JWT_SECRET` 改成 ≥32 字节强随机串(默认 `change-me-admin` 可伪造 token)
|
||||
- nginx 打开 IP 白名单(运营出口 IP)+ HTTPS 证书就位
|
||||
- `.env.production` 的 `NEXT_PUBLIC_API_BASE` 留空(同域,nginx 反代 /admin/api)
|
||||
- 首个 super_admin 已建、能登录
|
||||
|
||||
|
||||
@@ -32,6 +32,18 @@ server {
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# 用户上传媒体(上报截图 / 反馈截图 / 反馈二维码)由 App 后端(:8770)的 /media 托管。
|
||||
# admin 页面要展示这些图,经此同域反代过去(免跨域 + 免 https 页面引 http 图被拦)。
|
||||
# 前提:App 后端与本 admin 同机;若分机,把 127.0.0.1:8770 换成 App 后端可达地址。
|
||||
# 配好此段后,前端 .env.production 的 NEXT_PUBLIC_MEDIA_BASE 留空即走同域。
|
||||
location /media/ {
|
||||
proxy_pass http://127.0.0.1:8770;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# 其余 → Next.js 前端
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
|
||||
Generated
+874
-61
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
"@ant-design/cssinjs": "^1.24.0",
|
||||
"@ant-design/icons": "^5.5.2",
|
||||
"@ant-design/nextjs-registry": "^1.0.2",
|
||||
"@ant-design/plots": "^2.6.8",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"antd": "^5.22.0",
|
||||
"axios": "^1.7.7",
|
||||
|
||||
@@ -0,0 +1,839 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
App,
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popover,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
FallOutlined,
|
||||
InfoCircleOutlined,
|
||||
QuestionCircleOutlined,
|
||||
RiseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
import type {
|
||||
AdRevenueDaily,
|
||||
AdRevenueRecord,
|
||||
AdRevenueReport,
|
||||
AdRevenueRow,
|
||||
} from '@/lib/types';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// 广告类型标签
|
||||
const TYPE_TAG: Record<string, { color: string; label: string }> = {
|
||||
reward_video: { color: 'blue', label: '激励视频' },
|
||||
feed: { color: 'purple', label: '信息流' },
|
||||
draw: { color: 'geekblue', label: 'Draw 信息流' },
|
||||
};
|
||||
|
||||
// 我们的应用环境标签
|
||||
const APP_TAG: Record<string, { color: string; label: string }> = {
|
||||
prod: { color: 'green', label: '傻瓜比价(正式)' },
|
||||
test: { color: 'default', label: '测试应用' },
|
||||
};
|
||||
|
||||
// 发奖状态 → 颜色 + 中文(含「不发金币的原因」:提前关闭/未满10秒/缺eCPM/次数超限)
|
||||
const STATUS_TAG: Record<string, { color: string; label: string }> = {
|
||||
granted: { color: 'green', label: '已发' },
|
||||
capped: { color: 'orange', label: '次数超限' },
|
||||
ecpm_missing: { color: 'red', label: '缺 eCPM' },
|
||||
too_short: { color: 'gold', label: '未满10秒' },
|
||||
closed_early: { color: 'default', label: '提前关闭' },
|
||||
};
|
||||
|
||||
const fmtFactorRange = (a: number | null, b: number | null) => {
|
||||
if (a == null) return '-';
|
||||
return a === b || b == null ? String(a) : `${a}→${b}`;
|
||||
};
|
||||
|
||||
const fmtIndexRange = (a: number | null, b: number | null) => {
|
||||
if (a == null) return '-';
|
||||
return a === b ? String(a) : `${a}–${b}`;
|
||||
};
|
||||
|
||||
// 金币计算公式(写死,展示用)——与后端 app/core/rewards.py 同源,改动以后端常量为准。
|
||||
// 单次奖励(元) = (eCPM元 ÷ 1000) × 因子1(eCPM 元档) × 因子2(LT 累计条数);1 元 = 10000 金币,四舍五入取整。
|
||||
const COIN_PER_YUAN = 10000;
|
||||
// 因子1:按 eCPM(元/千次)判档,对应 AD_ECPM_FACTOR_TABLE
|
||||
const ECPM_FACTOR_ROWS = [
|
||||
{ key: '4', range: '> 400', factor: 0.6 },
|
||||
{ key: '3', range: '201 – 400', factor: 0.4 },
|
||||
{ key: '2', range: '101 – 200', factor: 0.3 },
|
||||
{ key: '1', range: '0 – 100', factor: 0.1 },
|
||||
];
|
||||
// 因子2:按该账号累计第几条/份(不按天重置),对应 AD_LT_FACTOR_TABLE
|
||||
const LT_FACTOR_ROWS = [
|
||||
{ key: '1', lt: '第 1 条', factor: 2.0 },
|
||||
{ key: '2', lt: '第 2 条', factor: 1.5 },
|
||||
{ key: '3', lt: '第 3 条', factor: 1.3 },
|
||||
{ key: '4', lt: '4 – 10 条', factor: 1.1 },
|
||||
{ key: '5', lt: '第 11 条及以后', factor: 1.0 },
|
||||
];
|
||||
|
||||
// 展开行(逐条事件展开)- 该条的发奖复算明细(还原金币审计的 eCPM/因子1/份数/LT/因子2 等列)
|
||||
const DETAIL_COLUMNS: ColumnsType<AdRevenueRecord> = [
|
||||
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (s: string) => {
|
||||
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
|
||||
return <Tag color={t.color}>{t.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: 'eCPM(分)', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' },
|
||||
{ title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' },
|
||||
{ title: '份数', dataIndex: 'units', width: 60 },
|
||||
{
|
||||
title: 'LT累计条数',
|
||||
key: 'lt_index',
|
||||
width: 100,
|
||||
render: (_: unknown, r: AdRevenueRecord) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
|
||||
},
|
||||
{
|
||||
title: '因子2',
|
||||
key: 'lt_factor',
|
||||
width: 90,
|
||||
render: (_: unknown, r: AdRevenueRecord) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end),
|
||||
},
|
||||
{ title: '应发金币', dataIndex: 'expected_coin', width: 100, render: (v: number) => <b>{v}</b> },
|
||||
{ title: '实发金币', dataIndex: 'actual_coin', width: 100 },
|
||||
{
|
||||
title: '一致',
|
||||
dataIndex: 'matched',
|
||||
width: 80,
|
||||
render: (m: boolean) => (m ? <Tag color="green">✓</Tag> : <Tag color="red">✗ 不符</Tag>),
|
||||
},
|
||||
];
|
||||
|
||||
// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),线=预估收益元(右轴)。x 轴按传入点序。
|
||||
const CHART_BAR = '#69b1ff';
|
||||
const CHART_LINE = '#fa8c16';
|
||||
|
||||
interface TrendPoint {
|
||||
label: string; // x 轴刻度文案(小时数 / MM-DD)
|
||||
tip: string; // hover 原生 tooltip 全文
|
||||
impressions: number;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
// 按小时聚合(0–23),数据源是 items(被 limit 截断时会偏少,调用处给警告)
|
||||
function aggregateHourly(rows: AdRevenueRow[]): TrendPoint[] {
|
||||
const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, impressions: 0, revenue: 0 }));
|
||||
for (const r of rows) {
|
||||
if (r.hour == null || r.hour < 0 || r.hour > 23) continue;
|
||||
byHour[r.hour].impressions += r.impressions;
|
||||
byHour[r.hour].revenue += r.revenue_yuan;
|
||||
}
|
||||
return byHour.map((b) => ({
|
||||
label: String(b.hour),
|
||||
tip: `${String(b.hour).padStart(2, '0')}:00 展示 ${b.impressions} 条 · 预估收益 ${b.revenue.toFixed(4)} 元`,
|
||||
impressions: b.impressions,
|
||||
revenue: b.revenue,
|
||||
}));
|
||||
}
|
||||
|
||||
// 按天聚合,数据源是 daily(全量,不受 limit 影响);用 from..to 补齐空缺日为 0,轴连续
|
||||
function aggregateDaily(dateFrom: string, dateTo: string, daily: AdRevenueDaily[]): TrendPoint[] {
|
||||
const map = new Map(daily.map((d) => [d.date, d]));
|
||||
const out: TrendPoint[] = [];
|
||||
let cur = dayjs(dateFrom);
|
||||
let guard = 0;
|
||||
while (cur.format('YYYY-MM-DD') <= dateTo && guard < 400) {
|
||||
const ds = cur.format('YYYY-MM-DD');
|
||||
const d = map.get(ds);
|
||||
const impressions = d?.impressions ?? 0;
|
||||
const revenue = d?.revenue_yuan ?? 0;
|
||||
out.push({
|
||||
label: ds.slice(5),
|
||||
tip: `${ds} 展示 ${impressions} 条 · 预估收益 ${revenue.toFixed(4)} 元`,
|
||||
impressions,
|
||||
revenue,
|
||||
});
|
||||
cur = cur.add(1, 'day');
|
||||
guard += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function TrendChart({ points }: { points: TrendPoint[] }) {
|
||||
const n = points.length;
|
||||
const maxImp = Math.max(1, ...points.map((p) => p.impressions));
|
||||
const maxRev = Math.max(1e-9, ...points.map((p) => p.revenue));
|
||||
|
||||
const W = 960;
|
||||
const H = 280;
|
||||
const padL = 48;
|
||||
const padR = 56;
|
||||
const padT = 16;
|
||||
const padB = 32;
|
||||
const plotW = W - padL - padR;
|
||||
const plotH = H - padT - padB;
|
||||
const step = plotW / Math.max(1, n);
|
||||
const barW = Math.min(28, step * 0.55);
|
||||
const yBase = padT + plotH;
|
||||
|
||||
const barX = (i: number) => padL + i * step + (step - barW) / 2;
|
||||
const cx = (i: number) => padL + i * step + step / 2;
|
||||
const impH = (v: number) => (v / maxImp) * plotH;
|
||||
const revY = (v: number) => yBase - (v / maxRev) * plotH;
|
||||
const linePts = points.map((p, i) => `${cx(i)},${revY(p.revenue)}`).join(' ');
|
||||
const labelEvery = Math.max(1, Math.ceil(n / 12)); // 至多 ~12 个 x 标签,避免拥挤
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }} role="img" aria-label="趋势图">
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((t) => {
|
||||
const y = yBase - t * plotH;
|
||||
return (
|
||||
<g key={t}>
|
||||
<line x1={padL} y1={y} x2={W - padR} y2={y} stroke="#f0f0f0" />
|
||||
<text x={padL - 8} y={y + 4} textAnchor="end" fontSize={11} fill={CHART_BAR}>
|
||||
{Math.round(maxImp * t)}
|
||||
</text>
|
||||
<text x={W - padR + 8} y={y + 4} textAnchor="start" fontSize={11} fill={CHART_LINE}>
|
||||
{(maxRev * t).toFixed(2)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{points.map((p, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={barX(i)}
|
||||
y={yBase - impH(p.impressions)}
|
||||
width={barW}
|
||||
height={impH(p.impressions)}
|
||||
fill={CHART_BAR}
|
||||
rx={2}
|
||||
>
|
||||
<title>{p.tip}</title>
|
||||
</rect>
|
||||
))}
|
||||
<polyline points={linePts} fill="none" stroke={CHART_LINE} strokeWidth={2} />
|
||||
{points.map((p, i) => (
|
||||
<circle key={i} cx={cx(i)} cy={revY(p.revenue)} r={2.5} fill={CHART_LINE}>
|
||||
<title>{p.tip}</title>
|
||||
</circle>
|
||||
))}
|
||||
{points.map((p, i) =>
|
||||
i % labelEvery === 0 || i === n - 1 ? (
|
||||
<text key={i} x={cx(i)} y={H - 14} textAnchor="middle" fontSize={11} fill="#999">
|
||||
{p.label}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// 广告数据页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出
|
||||
// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。
|
||||
// 广告配置面板:穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。
|
||||
// 客户端(发版后)从 /api/v1/platform/ad-config 拉取;此处经 admin /ad-config 读写。
|
||||
interface AdCfg {
|
||||
app_id: string;
|
||||
reward_code_id: string;
|
||||
compare_feed_code_id: string;
|
||||
coupon_feed_code_id: string;
|
||||
reward_mkey: string;
|
||||
reward_enabled: boolean;
|
||||
compare_ad_enabled: boolean;
|
||||
coupon_ad_enabled: boolean;
|
||||
}
|
||||
|
||||
function AdConfigPanel() {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.get<AdCfg>('/admin/api/ad-config');
|
||||
form.setFieldsValue(r.data);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e, '广告配置加载失败'));
|
||||
}
|
||||
}, [form, message]);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
const save = async () => {
|
||||
const v = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.patch('/admin/api/ad-config', v);
|
||||
message.success('已保存。客户端下次拉取生效(应用ID需冷启,广告位ID即时)');
|
||||
load();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e, '保存失败'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Card
|
||||
title="广告配置(穿山甲)"
|
||||
style={{ marginBottom: 16 }}
|
||||
extra={
|
||||
<Button type="primary" loading={saving} onClick={save}>
|
||||
保存
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
style={{ marginBottom: 16 }}
|
||||
message="应用ID改了客户端需冷启生效,广告位ID即时;换激励位必须同步换验签密钥(m-key),否则发不出金币。这些配置需客户端发版接入后才会读取。"
|
||||
/>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="app_id" label="穿山甲应用ID (APP_ID)" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 5830519" />
|
||||
</Form.Item>
|
||||
<Form.Item name="reward_code_id" label="福利页 · 激励视频 广告位ID" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 104099389" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="reward_mkey"
|
||||
label="福利激励 · 验签密钥 m-key(机密,不下发客户端;换激励位必须同步换;留空回退 .env)"
|
||||
>
|
||||
<Input.Password placeholder="GroMore S2S 回调验签密钥" autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item name="compare_feed_code_id" label="比价 · 信息流 广告位ID" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 104090333" />
|
||||
</Form.Item>
|
||||
<Form.Item name="coupon_feed_code_id" label="领券 · 信息流 广告位ID" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 104090333" />
|
||||
</Form.Item>
|
||||
<Form.Item name="reward_enabled" label="福利激励视频开关" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="compare_ad_enabled" label="比价广告开关" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="coupon_ad_enabled" label="领券广告开关" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdRevenuePage() {
|
||||
const { message } = App.useApp();
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
|
||||
const [userId, setUserId] = useState<number | null>(null);
|
||||
const [adType, setAdType] = useState<string | undefined>();
|
||||
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
||||
const [limit, setLimit] = useState<number>(500);
|
||||
const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列
|
||||
const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图
|
||||
const [data, setData] = useState<AdRevenueReport | null>(null);
|
||||
const [queriedLimit, setQueriedLimit] = useState<number>(500);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formulaOpen, setFormulaOpen] = useState(false);
|
||||
|
||||
// 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天
|
||||
const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const from = range[0].format('YYYY-MM-DD');
|
||||
const to = range[1].format('YYYY-MM-DD');
|
||||
const multiDay = from !== to;
|
||||
const gran = multiDay ? 'day' : granularity; // 跨多天强制按天
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
|
||||
params: {
|
||||
date_from: from,
|
||||
date_to: to,
|
||||
user_id: userId ?? undefined,
|
||||
ad_type: adType ?? undefined,
|
||||
granularity: gran,
|
||||
limit,
|
||||
},
|
||||
});
|
||||
setData(res.data);
|
||||
setQueriedLimit(limit);
|
||||
setQueriedGranularity(gran);
|
||||
setQueriedMultiDay(multiDay);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [range, userId, adType, granularity, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// 仅首次自动拉今天;之后由「查询」按钮触发
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<AdRevenueRow> = [
|
||||
...(queriedMultiDay
|
||||
? [{ title: '日期', dataIndex: 'report_date', width: 105, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
|
||||
: []),
|
||||
{ title: '时间', dataIndex: 'created_at', width: 165, render: (v: string) => formatUtcTime(v) },
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'user_phone',
|
||||
width: 150,
|
||||
render: (phone: string | null, r: AdRevenueRow) =>
|
||||
phone ? (
|
||||
<span>
|
||||
{phone}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 6 }}>
|
||||
#{r.user_id}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
) : (
|
||||
<Typography.Text type="secondary">#{r.user_id}(无手机号)</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '广告类型',
|
||||
dataIndex: 'ad_type',
|
||||
width: 110,
|
||||
render: (s: string) => {
|
||||
const t = TYPE_TAG[s] ?? { color: 'default', label: s };
|
||||
return <Tag color={t.color}>{t.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '来源应用',
|
||||
dataIndex: 'app_env',
|
||||
width: 140,
|
||||
render: (v: string | null) => {
|
||||
if (!v) return <Typography.Text type="secondary">未知</Typography.Text>;
|
||||
const t = APP_TAG[v] ?? { color: 'default', label: v };
|
||||
return <Tag color={t.color}>{t.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '广告位ID',
|
||||
dataIndex: 'our_code_id',
|
||||
width: 110,
|
||||
render: (v: string | null) =>
|
||||
v ? <Typography.Text code>{v}</Typography.Text> : <Typography.Text type="secondary">-</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: 'eCPM(分)',
|
||||
dataIndex: 'ecpm',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (v: string | null) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: '预估收益(元)',
|
||||
dataIndex: 'revenue_yuan',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: (v: number, r: AdRevenueRow) => (r.has_impression ? v.toFixed(4) : '-'),
|
||||
},
|
||||
{
|
||||
title: '发奖状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: string | null) => {
|
||||
if (!s) return <Tag>仅展示</Tag>;
|
||||
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
|
||||
return <Tag color={t.color}>{t.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '应发金币',
|
||||
dataIndex: 'expected_coin',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
render: (v: number, r: AdRevenueRow) =>
|
||||
r.has_reward ? <b>{v}</b> : <Typography.Text type="secondary">-</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '实发金币',
|
||||
dataIndex: 'actual_coin',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
render: (v: number, r: AdRevenueRow) =>
|
||||
r.has_reward ? v : <Typography.Text type="secondary">-</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '一致',
|
||||
dataIndex: 'matched',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
render: (m: boolean, r: AdRevenueRow) =>
|
||||
r.has_reward ? (
|
||||
m ? <Tag color="green">✓</Tag> : <Tag color="red">✗ 不符</Tag>
|
||||
) : (
|
||||
<Typography.Text type="secondary">-</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '底层 ADN',
|
||||
dataIndex: 'adn',
|
||||
width: 100,
|
||||
render: (v: string | null) =>
|
||||
v ? <Tag>{v}</Tag> : <Typography.Text type="secondary">-</Typography.Text>,
|
||||
},
|
||||
];
|
||||
|
||||
// 派生指标(全部基于全量 total_* 字段,不受 limit 截断影响,准):
|
||||
// 平均 eCPM = 收益÷展示×1000;发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;
|
||||
// 发奖占收益比 = 发奖成本÷收益;应发实发差额(金币)= 应发−实发(正=少发/负=多发)。
|
||||
const derived = data
|
||||
? {
|
||||
avgEcpm: data.total_impressions
|
||||
? (data.total_revenue_yuan / data.total_impressions) * 1000
|
||||
: 0,
|
||||
payoutYuan: data.total_actual_coin / COIN_PER_YUAN,
|
||||
grossProfit: data.total_revenue_yuan - data.total_actual_coin / COIN_PER_YUAN,
|
||||
payoutRatioPct:
|
||||
data.total_revenue_yuan > 0
|
||||
? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100
|
||||
: null,
|
||||
coinGap: data.total_expected_coin - data.total_actual_coin,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdConfigPanel />
|
||||
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0 }}>收益报表</h2>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<QuestionCircleOutlined />}
|
||||
onClick={() => setFormulaOpen(true)}
|
||||
>
|
||||
金币计算规则
|
||||
</Button>
|
||||
<Popover
|
||||
placement="bottomLeft"
|
||||
title="收益口径说明"
|
||||
content={
|
||||
<div style={{ maxWidth: 360, fontSize: 13, lineHeight: 1.7 }}>
|
||||
「预估收益」为客户端在广告展示(onAdShow)时上报 eCPM 折算的预估值(每千次展示 ÷1000
|
||||
累加),<b>只要广告展示就计入、不论是否看完发奖</b>;穿山甲会过滤无效/过短曝光,故预估值可能偏高,
|
||||
<b>实际收益一律以穿山甲后台结算为准</b>。测试应用多为 0。「广告位ID / 来源应用」为本期新增,历史记录留空。
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button type="link" size="small" icon={<InfoCircleOutlined />}>
|
||||
收益口径
|
||||
</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap size="middle" align="center">
|
||||
<Space size={6}>
|
||||
<Typography.Text type="secondary">日期</Typography.Text>
|
||||
<RangePicker
|
||||
value={range}
|
||||
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
|
||||
allowClear={false}
|
||||
presets={[
|
||||
{ label: '今天', value: [dayjs(), dayjs()] },
|
||||
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
|
||||
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<Space size={6}>
|
||||
<Typography.Text type="secondary">用户</Typography.Text>
|
||||
<InputNumber
|
||||
placeholder="可空"
|
||||
value={userId ?? undefined}
|
||||
onChange={(v) => setUserId(v ?? null)}
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
</Space>
|
||||
<Space size={6}>
|
||||
<Typography.Text type="secondary">类型</Typography.Text>
|
||||
<Select
|
||||
placeholder="全部"
|
||||
value={adType}
|
||||
onChange={setAdType}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
options={[
|
||||
{ value: 'reward_video', label: '激励视频' },
|
||||
{ value: 'feed', label: '信息流' },
|
||||
{ value: 'draw', label: 'Draw 信息流' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<Space size={6}>
|
||||
<Typography.Text type="secondary">粒度</Typography.Text>
|
||||
<Select
|
||||
value={rangeMultiDay ? 'day' : granularity}
|
||||
onChange={setGranularity}
|
||||
disabled={rangeMultiDay}
|
||||
style={{ width: 110 }}
|
||||
title={rangeMultiDay ? '跨多天仅支持按天' : undefined}
|
||||
options={[
|
||||
{ value: 'day', label: '按天' },
|
||||
{ value: 'hour', label: '按小时' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<Space size={6}>
|
||||
<Typography.Text type="secondary">条数</Typography.Text>
|
||||
<Select
|
||||
value={limit}
|
||||
onChange={setLimit}
|
||||
style={{ width: 110 }}
|
||||
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} 条` }))}
|
||||
/>
|
||||
</Space>
|
||||
<Button type="primary" onClick={load} loading={loading}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{data && derived && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Divider orientation="left" plain style={{ marginTop: 0, marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
广告展示与收益
|
||||
</Typography.Text>
|
||||
</Divider>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col span={6}>
|
||||
<Statistic title="展示条数合计" value={data.total_impressions} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="平均 eCPM(元/千次)" value={derived.avgEcpm} precision={2} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="预估收益合计(元)" value={data.total_revenue_yuan} precision={4} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="广告事件数" value={data.total} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
发奖与对账
|
||||
</Typography.Text>
|
||||
</Divider>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col span={6}>
|
||||
<Statistic title="应发金币合计" value={data.total_expected_coin} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="实发金币合计" value={data.total_actual_coin} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="发奖成本(元)" value={derived.payoutYuan} precision={4} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="预估毛利(元)"
|
||||
value={derived.grossProfit}
|
||||
precision={4}
|
||||
prefix={derived.grossProfit >= 0 ? <RiseOutlined /> : <FallOutlined />}
|
||||
valueStyle={{ color: derived.grossProfit >= 0 ? '#3f8600' : '#cf1322' }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
发奖占收益{' '}
|
||||
{derived.payoutRatioPct == null ? '—' : `${derived.payoutRatioPct.toFixed(0)}%`}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<Space direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
|
||||
{data.mismatch_count > 0 ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={`共 ${data.total} 条广告事件,其中 ${data.mismatch_count} 条应发≠实发(✗);应发−实发 合计 ${
|
||||
derived && derived.coinGap >= 0 ? '+' : ''
|
||||
}${derived?.coinGap ?? 0} 金币(正=少发/负=多发)——公式可能未生效或发奖有问题。定位到具体记录可用后端逐条审计接口。`}
|
||||
/>
|
||||
) : (
|
||||
<Alert type="success" showIcon message={`共 ${data.total} 条广告事件,应发与实发全部一致。`} />
|
||||
)}
|
||||
{data.truncated && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`明细已截断到 ${queriedLimit} 条,还有更多未显示——请按用户/类型/日期缩小范围,或调大「条数」。合计数字不受影响。`}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{data &&
|
||||
(queriedMultiDay
|
||||
? (data.daily?.length ?? 0) > 0
|
||||
: queriedGranularity === 'hour' && (data.items?.length ?? 0) > 0) && (
|
||||
<Card
|
||||
size="small"
|
||||
title={queriedMultiDay ? '按天趋势' : '按小时趋势'}
|
||||
style={{ marginBottom: 16 }}
|
||||
extra={
|
||||
<Space size={16}>
|
||||
<span style={{ fontSize: 12, color: '#666' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: CHART_BAR,
|
||||
borderRadius: 2,
|
||||
marginRight: 4,
|
||||
}}
|
||||
/>
|
||||
展示条数
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: '#666' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 14,
|
||||
borderTop: `2px solid ${CHART_LINE}`,
|
||||
marginRight: 4,
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
预估收益(元)
|
||||
</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{/* 按小时图由 items 聚合,会被 limit 截断;按天图用 daily(全量)不受影响,故只在按小时时警告 */}
|
||||
{!queriedMultiDay && data.truncated && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={`图表按小时聚合的是已截断到 ${queriedLimit} 组的明细,可能少算——调大「条数」或缩小筛选可还原真实趋势。`}
|
||||
/>
|
||||
)}
|
||||
{queriedMultiDay ? (
|
||||
<TrendChart points={aggregateDaily(data.date_from, data.date_to, data.daily)} />
|
||||
) : (
|
||||
<TrendChart points={aggregateHourly(data.items)} />
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Table
|
||||
rowKey="event_key"
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 1200 }}
|
||||
rowClassName={(r) => (r.has_reward && !r.matched ? 'row-mismatch' : '')}
|
||||
expandable={{
|
||||
// 只有发了奖的事件可展开,看「这条广告金币怎么算的」;纯展示行无复算、不可展开。
|
||||
rowExpandable: (r) => r.reward_detail != null,
|
||||
expandedRowRender: (r) =>
|
||||
r.reward_detail ? (
|
||||
<div>
|
||||
<Typography.Text strong>金币复算明细</Typography.Text>{' '}
|
||||
<Typography.Text type="secondary">
|
||||
(本条广告 应发 {r.reward_detail.expected_coin} / 实发 {r.reward_detail.actual_coin})
|
||||
</Typography.Text>
|
||||
<Table<AdRevenueRecord>
|
||||
style={{ marginTop: 8 }}
|
||||
rowKey="record_id"
|
||||
columns={DETAIL_COLUMNS}
|
||||
dataSource={[r.reward_detail]}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 900 }}
|
||||
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="金币计算规则"
|
||||
open={formulaOpen}
|
||||
onCancel={() => setFormulaOpen(false)}
|
||||
footer={null}
|
||||
width={680}
|
||||
>
|
||||
<Typography.Paragraph>
|
||||
<b>单次奖励(元)</b> =(单次 eCPM<sub>元</sub> ÷ 1000)× <b>因子1</b>(eCPM 档)× <b>因子2</b>(LT 累计条数)
|
||||
</Typography.Paragraph>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 13 }}>
|
||||
· eCPM 取自穿山甲 SDK <Typography.Text code>getEcpm()</Typography.Text> 原值,单位「分/千次展示」,计算时 ÷100 转元;单次收益按「每千次 ÷1000」折到每次。
|
||||
<br />· 金币汇率:<b>1 元 = {COIN_PER_YUAN.toLocaleString()} 金币</b>,最终四舍五入取整。
|
||||
<br />· 因子2「LT 累计条数」按该账号累计第几条计(不按天重置);激励视频每次 1 条,信息流每满 10 秒折 1 条。
|
||||
<br />· 本表与发奖后端同源,本页「应发金币」即按此复算并与「实发金币」对账。
|
||||
</Typography.Paragraph>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Typography.Text strong>因子1 ·按 eCPM(元/千次)判档</Typography.Text>
|
||||
<Table
|
||||
style={{ marginTop: 8 }}
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={ECPM_FACTOR_ROWS}
|
||||
columns={[
|
||||
{ title: 'eCPM 范围(元)', dataIndex: 'range' },
|
||||
{ title: '因子1', dataIndex: 'factor', width: 80 },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Typography.Text strong>因子2 ·按账号 LT 累计条数</Typography.Text>
|
||||
<Table
|
||||
style={{ marginTop: 8 }}
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={LT_FACTOR_ROWS}
|
||||
columns={[
|
||||
{ title: 'LT 累计条数', dataIndex: 'lt' },
|
||||
{ title: '因子2', dataIndex: 'factor', width: 80 },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginTop: 12, marginBottom: 0 }}>
|
||||
示例:eCPM = 300 元/千次、账号第 1 条 → 0.3 × 0.4 × 2.0 = 0.24 元 = 2,400 金币。
|
||||
</Typography.Paragraph>
|
||||
</Modal>
|
||||
|
||||
<style jsx global>{`
|
||||
.row-mismatch > td {
|
||||
background: #fff1f0 !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Button, Form, Input, Modal, Select, Table, Tag, message } from 'antd';
|
||||
import { App, Button, Form, Input, Modal, Select, Table, Tag } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
import type { AdminInfo } from '@/lib/types';
|
||||
|
||||
const ROLES = [
|
||||
@@ -11,9 +12,11 @@ const ROLES = [
|
||||
{ value: 'finance', label: 'finance' },
|
||||
{ value: 'operator', label: 'operator' },
|
||||
];
|
||||
const dt = (v: string | null) => (v ? new Date(v).toLocaleString('zh-CN') : '从未');
|
||||
// last_login_at 为 UTC 口径(datetime.now(utc)),按北京显示(勿用 new Date().toLocaleString)
|
||||
const dt = (v: string | null) => (v ? formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss') : '从未');
|
||||
|
||||
export default function AdminsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [admins, setAdmins] = useState<AdminInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@@ -47,7 +50,7 @@ export default function AdminsPage() {
|
||||
|
||||
const changeRole = (a: AdminInfo, role: string) => {
|
||||
if (role === a.role) return;
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `把 ${a.username} 的角色改为 ${role}?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
@@ -63,7 +66,7 @@ export default function AdminsPage() {
|
||||
|
||||
const toggle = (a: AdminInfo) => {
|
||||
const next = a.status === 'active' ? 'disabled' : 'active';
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `${next === 'disabled' ? '禁用' : '启用'}管理员 ${a.username}?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
|
||||
@@ -3,19 +3,18 @@
|
||||
import { useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Button, Input, Space, Table, Tag } from 'antd';
|
||||
import { useCursorList } from '@/lib/useCursorList';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
import { usePagedList } from '@/lib/usePagedList';
|
||||
import type { AuditLog } from '@/lib/types';
|
||||
|
||||
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
|
||||
// 审计 created_at 为 UTC 口径(server_default now()),按北京显示(勿用 new Date().toLocaleString)
|
||||
const dt = (v: string) => formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const [action, setAction] = useState('');
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
||||
const { items, nextCursor, loading, loadMore } = useCursorList<AuditLog>(
|
||||
'/admin/api/audit-logs',
|
||||
filters,
|
||||
50,
|
||||
);
|
||||
const { items, total, page, pageSize, loading, onChange: onPageChange } =
|
||||
usePagedList<AuditLog>('/admin/api/audit-logs', filters, 50);
|
||||
|
||||
const search = () => setFilters({ action: action || undefined });
|
||||
|
||||
@@ -59,14 +58,16 @@ export default function AuditLogsPage() {
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: onPageChange,
|
||||
}}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||
{nextCursor ? '加载更多' : '没有更多了'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import {
|
||||
App, Button, Card, Collapse, Descriptions, Drawer, Input, InputNumber,
|
||||
Select, Space, Spin, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { formatWallTime, yuan } from '@/lib/format';
|
||||
import { usePagedList } from '@/lib/usePagedList';
|
||||
import type { ComparisonRecordDetail, ComparisonRecordListItem } from '@/lib/types';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = { success: 'green', failed: 'red', cancelled: 'default' };
|
||||
const STATUS_LABEL: Record<string, string> = { success: '成功', failed: '失败', cancelled: '已终止' };
|
||||
|
||||
// 「卡在哪一步」: platform_results[*].status 翻成人话(找店/加菜/起送/读价)
|
||||
const STUCK_LABEL: Record<string, string> = {
|
||||
success: '成功',
|
||||
store_not_found: '没找到店',
|
||||
items_not_found: '菜没匹配上',
|
||||
below_minimum: '未达起送',
|
||||
unsupported: '平台不支持',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
const fmtMs = (ms: number | null) => (ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`);
|
||||
const cents = (c: number | null) => (c == null ? '-' : yuan(c));
|
||||
|
||||
// LLM 成本估算:(input + output) token / 1e6 × 每百万 token 单价(元)。单价由页面输入框配置(localStorage)。
|
||||
const calcCost = (inTok: number | null, outTok: number | null, price: number | null): number | null => {
|
||||
if (price == null || Number.isNaN(price)) return null;
|
||||
if (inTok == null && outTok == null) return null;
|
||||
return (((inTok ?? 0) + (outTok ?? 0)) / 1_000_000) * price;
|
||||
};
|
||||
const fmtCost = (c: number | null) => (c == null ? '-' : `¥${c.toFixed(4)}`);
|
||||
const fmtTok = (inTok: number | null, outTok: number | null) =>
|
||||
inTok == null && outTok == null ? '-' : `${inTok ?? 0}/${outTok ?? 0}`;
|
||||
|
||||
// LLM message content 常是 json.dumps 的卡片列表;能 parse 成 JSON 就缩进展开,否则原样。
|
||||
function pretty(s: string | null): string {
|
||||
if (!s) return '';
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(s), null, 2);
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
const preStyle: CSSProperties = {
|
||||
background: '#f6f8fa', padding: 8, borderRadius: 6, fontSize: 12,
|
||||
maxHeight: 340, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0,
|
||||
};
|
||||
|
||||
export default function ComparisonRecordsPage() {
|
||||
const { message } = App.useApp();
|
||||
|
||||
const [userId, setUserId] = useState<number | null>(null);
|
||||
const [phone, setPhone] = useState('');
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [applied, setApplied] = useState<Record<string, unknown>>({});
|
||||
// LLM 单价(元/百万 token),本地持久化;仅用于前端估算成本,不入库、不影响其它页面
|
||||
const [pricePerMTok, setPricePerMTok] = useState<number | null>(() => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const v = localStorage.getItem('llm_price_per_mtok');
|
||||
return v != null && v !== '' ? Number(v) : null;
|
||||
});
|
||||
const onPriceChange = (v: number | null) => {
|
||||
setPricePerMTok(v);
|
||||
if (typeof window === 'undefined') return;
|
||||
if (v == null) localStorage.removeItem('llm_price_per_mtok');
|
||||
else localStorage.setItem('llm_price_per_mtok', String(v));
|
||||
};
|
||||
|
||||
const { items, total, page, pageSize, loading, onChange } =
|
||||
usePagedList<ComparisonRecordListItem>('/admin/api/comparison-records', applied);
|
||||
|
||||
const [detail, setDetail] = useState<ComparisonRecordDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const search = () =>
|
||||
setApplied({ user_id: userId ?? undefined, phone: phone || undefined, status });
|
||||
|
||||
const reset = () => {
|
||||
setUserId(null);
|
||||
setPhone('');
|
||||
setStatus(undefined);
|
||||
setApplied({});
|
||||
};
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
setDetailLoading(true);
|
||||
setDetail(null);
|
||||
try {
|
||||
const { data } = await api.get<ComparisonRecordDetail>(`/admin/api/comparison-records/${id}`);
|
||||
setDetail(data);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDetail = () => {
|
||||
setDetail(null);
|
||||
setDetailLoading(false);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ComparisonRecordListItem> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 64 },
|
||||
{
|
||||
title: '用户',
|
||||
key: 'user',
|
||||
width: 140,
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
<div>{r.phone || `#${r.user_id}`}</div>
|
||||
{r.nickname && <div style={{ color: '#999', fontSize: 12 }}>{r.nickname}</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 72, render: (s: string) => <Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s] || s}</Tag> },
|
||||
{ title: '源平台', dataIndex: 'source_platform_name', width: 90, render: (v) => v || '-' },
|
||||
{ title: '店/商品', dataIndex: 'store_name', width: 150, ellipsis: true, render: (v) => v || '-' },
|
||||
{ title: '最优', dataIndex: 'best_platform_name', width: 80, render: (v) => v || '-' },
|
||||
{
|
||||
title: '省',
|
||||
key: 'saved',
|
||||
width: 84,
|
||||
render: (_, r) =>
|
||||
r.saved_amount_cents == null ? '-' : (
|
||||
<b style={{ color: r.saved_amount_cents > 0 ? '#3f8600' : '#999' }}>{yuan(r.saved_amount_cents)}</b>
|
||||
),
|
||||
},
|
||||
{ title: '耗时', dataIndex: 'total_ms', width: 64, render: fmtMs },
|
||||
{ title: '步', dataIndex: 'step_count', width: 48, render: (v) => v ?? '-' },
|
||||
{ title: 'LLM', dataIndex: 'llm_call_count', width: 56, render: (v) => v ?? '-' },
|
||||
{
|
||||
title: '重试',
|
||||
dataIndex: 'retry_count',
|
||||
width: 50,
|
||||
render: (v: number | null) => (v ? <span style={{ color: '#cf1322' }}>{v}</span> : v ?? '-'),
|
||||
},
|
||||
{
|
||||
title: 'Token(入/出)',
|
||||
key: 'tokens',
|
||||
width: 110,
|
||||
render: (_, r) => fmtTok(r.input_tokens, r.output_tokens),
|
||||
},
|
||||
{
|
||||
title: '成本',
|
||||
key: 'cost',
|
||||
width: 90,
|
||||
render: (_, r) => fmtCost(calcCost(r.input_tokens, r.output_tokens, pricePerMTok)),
|
||||
},
|
||||
{
|
||||
title: '机型/ROM',
|
||||
key: 'device',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (_, r) => [r.device_model, r.rom_name].filter(Boolean).join(' / ') || '-',
|
||||
},
|
||||
{ title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'op',
|
||||
fixed: 'right',
|
||||
width: 80,
|
||||
render: (_, r) =>
|
||||
r.trace_url ? (
|
||||
<a href={r.trace_url} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>trace</a>
|
||||
) : (
|
||||
<span style={{ color: '#ccc' }}>-</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const platformResults =
|
||||
(detail?.raw_payload?.platform_results as Record<string, unknown>[] | undefined) || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>比价记录(debug)</h2>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<InputNumber placeholder="user_id" value={userId} onChange={setUserId} style={{ width: 110 }} />
|
||||
<Input
|
||||
placeholder="手机号前缀"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
allowClear
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ value: 'success', label: '成功' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
{ value: 'cancelled', label: '已终止' },
|
||||
]}
|
||||
/>
|
||||
<Button type="primary" onClick={search}>查询</Button>
|
||||
<Button onClick={reset}>重置</Button>
|
||||
<InputNumber
|
||||
placeholder="LLM 单价"
|
||||
value={pricePerMTok}
|
||||
onChange={onPriceChange}
|
||||
min={0}
|
||||
step={0.1}
|
||||
style={{ width: 210 }}
|
||||
addonAfter="元/百万token"
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange,
|
||||
}}
|
||||
scroll={{ x: 1520 }}
|
||||
onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })}
|
||||
/>
|
||||
|
||||
<Drawer title={detail ? `比价记录 #${detail.id}` : '详情'} width={780} open={detailLoading || !!detail} onClose={closeDetail}>
|
||||
{detailLoading && <Spin />}
|
||||
{detail && (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Descriptions size="small" column={2} bordered>
|
||||
<Descriptions.Item label="用户">
|
||||
{detail.phone || `#${detail.user_id}`}{detail.nickname ? `(${detail.nickname})` : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态"><Tag color={STATUS_COLOR[detail.status]}>{STATUS_LABEL[detail.status] || detail.status}</Tag></Descriptions.Item>
|
||||
<Descriptions.Item label="业务">{detail.business_type}</Descriptions.Item>
|
||||
<Descriptions.Item label="耗时">{fmtMs(detail.total_ms)}</Descriptions.Item>
|
||||
<Descriptions.Item label="步数">{detail.step_count ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="LLM 次数/重试">{detail.llm_call_count ?? '-'} / {detail.retry_count ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Token(入/出/合计)">
|
||||
{detail.input_tokens == null && detail.output_tokens == null
|
||||
? '-'
|
||||
: `${detail.input_tokens ?? 0} / ${detail.output_tokens ?? 0} / ${(detail.input_tokens ?? 0) + (detail.output_tokens ?? 0)}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="估算成本">
|
||||
{fmtCost(calcCost(detail.input_tokens, detail.output_tokens, pricePerMTok))}
|
||||
{pricePerMTok == null && (detail.input_tokens != null || detail.output_tokens != null)
|
||||
? <span style={{ color: '#999', fontSize: 12 }}>(顶部填 LLM 单价后显示)</span>
|
||||
: null}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="源平台">{detail.source_platform_name || '-'}({cents(detail.source_price_cents)})</Descriptions.Item>
|
||||
<Descriptions.Item label="最优">{detail.best_platform_name || '-'}({cents(detail.best_price_cents)})</Descriptions.Item>
|
||||
<Descriptions.Item label="省" span={2}>{cents(detail.saved_amount_cents)}{detail.is_source_best ? '(源平台最便宜)' : ''}</Descriptions.Item>
|
||||
<Descriptions.Item label="店/商品" span={2}>{detail.store_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="结论文案" span={2}>{detail.information || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="trace" span={2}>
|
||||
{detail.trace_url
|
||||
? <a href={detail.trace_url} target="_blank" rel="noreferrer">{detail.trace_url}</a>
|
||||
: <span style={{ color: '#999' }}>{detail.trace_id}(无 trace_url)</span>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="定位" span={2}>
|
||||
{detail.longitude != null ? `${detail.longitude}, ${detail.latitude}` : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最优深链" span={2}>
|
||||
{detail.best_deeplink
|
||||
? <Typography.Text copyable={{ text: detail.best_deeplink }} style={{ fontSize: 12, wordBreak: 'break-all' }}>{detail.best_deeplink}</Typography.Text>
|
||||
: '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Card size="small" title="设备环境">
|
||||
<Descriptions size="small" column={2}>
|
||||
<Descriptions.Item label="机型">{detail.device_model || '-'}({detail.device_manufacturer || '-'})</Descriptions.Item>
|
||||
<Descriptions.Item label="ROM">{[detail.rom_vendor, detail.rom_name, detail.rom_version].filter(Boolean).join(' ') || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Android">{detail.android_version || '-'}(SDK {detail.android_sdk ?? '-'})</Descriptions.Item>
|
||||
<Descriptions.Item label="App 版本">{detail.app_version || '-'}({detail.app_version_code ?? '-'})</Descriptions.Item>
|
||||
<Descriptions.Item label="源 App 版本" span={2}>{detail.source_app_version || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="device_id" span={2}>{detail.device_id || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{((detail.items?.length ?? 0) > 0 || (detail.skipped_dish_names?.length ?? 0) > 0) && (
|
||||
<Card size="small" title={`下单商品${detail.total_dish_count != null ? `(共 ${detail.total_dish_count})` : ''}`}>
|
||||
{(detail.items?.length ?? 0) > 0 ? (
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{detail.items.map((it, i) => (
|
||||
<li key={i}>
|
||||
{it.name}{it.qty != null ? ` ×${it.qty}` : ''}
|
||||
{it.specs?.length ? <span style={{ color: '#999' }}>({it.specs.join('/')})</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <Typography.Text type="secondary">无商品明细</Typography.Text>}
|
||||
{(detail.skipped_dish_names?.length ?? 0) > 0 && (
|
||||
<div style={{ marginTop: 8, color: '#cf1322' }}>
|
||||
跳过{detail.skipped_dish_count != null ? ` ${detail.skipped_dish_count} ` : ''}个:{detail.skipped_dish_names.join('、')}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{platformResults.length > 0 && (
|
||||
<Card size="small" title="逐平台结局(卡在哪一步)">
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(_, i) => String(i)}
|
||||
pagination={false}
|
||||
dataSource={platformResults}
|
||||
columns={[
|
||||
{ title: '平台', dataIndex: 'platform_name', render: (v, r: Record<string, unknown>) => (v as string) || (r.platform_id as string) || '-' },
|
||||
{ title: '结局', dataIndex: 'status', render: (s: string) => <Tag color={s === 'success' ? 'green' : 'orange'}>{STUCK_LABEL[s] || s || '-'}</Tag> },
|
||||
{ title: '说明', dataIndex: 'reason', render: (v) => (v as string) || '-' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{(detail.comparison_results?.length ?? 0) > 0 && (
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[{
|
||||
key: 'cmp',
|
||||
label: `逐平台对比(${detail.comparison_results.length})`,
|
||||
children: <pre style={preStyle}>{JSON.stringify(detail.comparison_results, null, 2)}</pre>,
|
||||
}]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card size="small" title={`LLM 调用明细(${detail.llm_calls?.length ?? 0} 次)`}>
|
||||
{detail.llm_calls && detail.llm_calls.length > 0 ? (
|
||||
<Collapse
|
||||
size="small"
|
||||
items={detail.llm_calls.map((c, i) => ({
|
||||
key: String(i),
|
||||
label: (
|
||||
<Space size={6} wrap>
|
||||
<Tag color="blue">{c.scene || 'llm'}</Tag>
|
||||
<span style={{ color: '#999', fontSize: 12 }}>{c.model}</span>
|
||||
<span style={{ color: '#999', fontSize: 12 }}>{c.latency_ms ?? '-'}ms</span>
|
||||
{c.usage?.total_tokens ? <span style={{ color: '#999', fontSize: 12 }}>{c.usage.total_tokens} tok</span> : null}
|
||||
{c.error ? <Tag color="red">error</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
{c.input_messages.map((m, j) => (
|
||||
<div key={j} style={{ marginBottom: 8 }}>
|
||||
<Tag>{m.role}</Tag>
|
||||
<pre style={preStyle}>{pretty(m.content)}</pre>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Tag color="green">output</Tag>
|
||||
<pre style={preStyle}>{c.error ? `【error】${c.error}` : pretty(c.output)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="secondary">无 LLM 调用明细(旧记录 / pricebot 未采集到 / 本次未调 LLM)</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[{
|
||||
key: 'raw',
|
||||
label: '原始上报 raw_payload',
|
||||
children: <pre style={preStyle}>{JSON.stringify(detail.raw_payload, null, 2)}</pre>,
|
||||
}]}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Input, InputNumber, Space, Spin, Tag, Tooltip, message } from 'antd';
|
||||
import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tag, Tooltip } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
|
||||
interface ConfigItem {
|
||||
key: string;
|
||||
label: string;
|
||||
group: string;
|
||||
type: string; // int / int_list / dict_str_int
|
||||
type: string; // int / int_list / dict_str_int / bool
|
||||
help: string | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: any;
|
||||
@@ -28,6 +28,7 @@ function toEdit(item: ConfigItem): any {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function fromEdit(type: string, raw: any): any {
|
||||
if (type === 'int') return Number(raw);
|
||||
if (type === 'bool') return Boolean(raw);
|
||||
if (type === 'int_list') {
|
||||
return String(raw)
|
||||
.split(',')
|
||||
@@ -38,6 +39,7 @@ function fromEdit(type: string, raw: any): any {
|
||||
}
|
||||
|
||||
export default function ConfigPage() {
|
||||
const { message } = App.useApp();
|
||||
const [items, setItems] = useState<ConfigItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -109,7 +111,12 @@ export default function ConfigPage() {
|
||||
)}
|
||||
</div>
|
||||
<Space wrap>
|
||||
{item.type === 'int' ? (
|
||||
{item.type === 'bool' ? (
|
||||
<Switch
|
||||
checked={!!edits[item.key]}
|
||||
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
|
||||
/>
|
||||
) : item.type === 'int' ? (
|
||||
<InputNumber
|
||||
value={edits[item.key]}
|
||||
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
'use client';
|
||||
|
||||
// CPS 群对账详情:该群点击量/独立点击/复制/独立复制的天级 or 小时级变化折线图。
|
||||
// 时间范围: 近 3/7/14/30 天(天级) 或 今日(小时级)。数据由后端补零成连续桶,折线不断裂。
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Button, Card, Col, Empty, Row, Segmented, Space, Spin, Statistic, Table, message } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { formatUtcTime, yuan } from '@/lib/format';
|
||||
|
||||
// @ant-design/plots 依赖 DOM,关掉 SSR 仅在客户端渲染(否则 Next 预渲染报 document undefined)。
|
||||
const Line = dynamic(() => import('@ant-design/plots').then((m) => m.Line), { ssr: false });
|
||||
|
||||
interface TsPoint {
|
||||
label: string;
|
||||
click_pv: number;
|
||||
click_uv: number;
|
||||
copy_pv: number;
|
||||
copy_uv: number;
|
||||
}
|
||||
interface TsResp {
|
||||
group_id: number;
|
||||
group_name: string;
|
||||
granularity: string;
|
||||
points: TsPoint[];
|
||||
}
|
||||
|
||||
// 按天大表格一行(订单侧字段对淘宝/京东群为 null → 显示 "-")
|
||||
interface DailyRow {
|
||||
date: string;
|
||||
click_pv: number;
|
||||
click_uv: number;
|
||||
copy_pv: number;
|
||||
order_count: number | null;
|
||||
canceled_count: number | null;
|
||||
gmv_cents: number | null;
|
||||
est_commission_cents: number | null;
|
||||
refund_profit_cents: number | null;
|
||||
settled_commission_cents: number | null;
|
||||
}
|
||||
interface DailyResp {
|
||||
group_name: string;
|
||||
is_meituan: boolean;
|
||||
days: number;
|
||||
rows: DailyRow[];
|
||||
}
|
||||
|
||||
// 群内微信用户(落地页授权拿到;nickname/headimgurl 仅 userinfo 授权后有)
|
||||
interface WxUser {
|
||||
openid: string;
|
||||
nickname: string | null;
|
||||
headimgurl: string | null;
|
||||
first_seen: string;
|
||||
visit_count: number;
|
||||
copy_count: number;
|
||||
}
|
||||
|
||||
const RANGES = [
|
||||
{ label: '近 3 天', value: 'd3' },
|
||||
{ label: '近 7 天', value: 'd7' },
|
||||
{ label: '近 14 天', value: 'd14' },
|
||||
{ label: '近 30 天', value: 'd30' },
|
||||
{ label: '今日(按小时)', value: 'h' },
|
||||
];
|
||||
|
||||
const METRICS: { key: keyof Omit<TsPoint, 'label'>; name: string }[] = [
|
||||
{ key: 'click_pv', name: '点击量' },
|
||||
{ key: 'click_uv', name: '独立点击' },
|
||||
{ key: 'copy_pv', name: '复制次数' },
|
||||
{ key: 'copy_uv', name: '独立复制' },
|
||||
];
|
||||
|
||||
function rangeToQuery(r: string): string {
|
||||
if (r === 'h') return 'granularity=hour';
|
||||
const days: Record<string, number> = { d3: 3, d7: 7, d14: 14, d30: 30 };
|
||||
return `granularity=day&days=${days[r] ?? 7}`;
|
||||
}
|
||||
|
||||
// 大表格只按天:天级范围 → 天数;今日小时(h) → null(此时隐藏表格,小时维度看上面折线)
|
||||
function rangeToDays(r: string): number | null {
|
||||
return ({ d3: 3, d7: 7, d14: 14, d30: 30 } as Record<string, number>)[r] ?? null;
|
||||
}
|
||||
|
||||
export default function GroupDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = params.id as string;
|
||||
const [range, setRange] = useState('d7');
|
||||
const [data, setData] = useState<TsResp | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.get<TsResp>(
|
||||
`/admin/api/cps/groups/${id}/timeseries?${rangeToQuery(range)}`,
|
||||
);
|
||||
setData(r.data);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e, '加载失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, range]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// 按天大表格(独立于折线;今日小时模式 tableDays=null 时不取、不显示)
|
||||
const [daily, setDaily] = useState<DailyResp | null>(null);
|
||||
const tableDays = rangeToDays(range);
|
||||
const loadDaily = useCallback(async () => {
|
||||
if (tableDays == null) return;
|
||||
try {
|
||||
const r = await api.get<DailyResp>(`/admin/api/cps/groups/${id}/daily?days=${tableDays}`);
|
||||
setDaily(r.data);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e, '明细加载失败'));
|
||||
}
|
||||
}, [id, tableDays]);
|
||||
useEffect(() => {
|
||||
loadDaily();
|
||||
}, [loadDaily]);
|
||||
|
||||
// 群内微信用户(独立于时间范围,累计画像)
|
||||
const [wxUsers, setWxUsers] = useState<WxUser[]>([]);
|
||||
const loadWxUsers = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.get<{ users: WxUser[] }>(`/admin/api/cps/groups/${id}/wx-users`);
|
||||
setWxUsers(r.data.users || []);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e, '用户列表加载失败'));
|
||||
}
|
||||
}, [id]);
|
||||
useEffect(() => {
|
||||
loadWxUsers();
|
||||
}, [loadWxUsers]);
|
||||
|
||||
const points = useMemo(() => data?.points ?? [], [data]);
|
||||
// 展平成「长表」给折线图:每个 (时间桶 × 指标) 一行,colorField 按指标分 4 条线。
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
points.flatMap((p) =>
|
||||
METRICS.map((m) => ({ label: p.label, 指标: m.name, value: Number(p[m.key]) })),
|
||||
),
|
||||
[points],
|
||||
);
|
||||
const totals = useMemo(
|
||||
() => METRICS.map((m) => ({ name: m.name, sum: points.reduce((s, p) => s + Number(p[m.key]), 0) })),
|
||||
[points],
|
||||
);
|
||||
const hasData = points.some((p) => p.click_pv || p.click_uv || p.copy_pv || p.copy_uv);
|
||||
|
||||
const config = {
|
||||
data: chartData,
|
||||
xField: 'label',
|
||||
yField: 'value',
|
||||
colorField: '指标',
|
||||
height: 420,
|
||||
legend: { color: { position: 'top' as const } },
|
||||
// 折线带数据点(每桶一个);大小用 style.r 固定 —— sizeField/shapeField 是数据字段映射,
|
||||
// 给常量会被 G2 当字段名找而失效。
|
||||
point: { style: { r: 3 } },
|
||||
// y 轴恒从 0 起、上限自适应取整;CPS 数据量小,固定 0 基线对比更直观。
|
||||
scale: { y: { domainMin: 0, nice: true } },
|
||||
axis: { x: { title: range === 'h' ? '小时' : '日期' }, y: { title: '次数' } },
|
||||
interaction: { tooltip: { shared: true } },
|
||||
};
|
||||
|
||||
const dailyColumns: ColumnsType<DailyRow> = [
|
||||
{ title: '日期', dataIndex: 'date', width: 64, fixed: 'left' },
|
||||
{ title: '点击', dataIndex: 'click_pv', width: 56 },
|
||||
{ title: '独立', dataIndex: 'click_uv', width: 56 },
|
||||
{ title: '复制', dataIndex: 'copy_pv', width: 56, render: (v: number) => v || '-' },
|
||||
{
|
||||
title: '点击→下单',
|
||||
key: 'rate',
|
||||
width: 86,
|
||||
render: (_, r) =>
|
||||
r.order_count == null
|
||||
? '-'
|
||||
: r.click_uv
|
||||
? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%`
|
||||
: '-',
|
||||
},
|
||||
{ title: '有效单', dataIndex: 'order_count', width: 64, render: (v: number | null) => (v == null ? '-' : v) },
|
||||
{
|
||||
title: '取消/风控',
|
||||
dataIndex: 'canceled_count',
|
||||
width: 78,
|
||||
render: (v: number | null) => (v == null ? '-' : v ? <span style={{ color: '#cf1322' }}>{v}</span> : 0),
|
||||
},
|
||||
{ title: '成交额', dataIndex: 'gmv_cents', width: 84, render: (v: number | null) => (v == null ? '-' : yuan(v)) },
|
||||
{
|
||||
title: '预估佣金',
|
||||
dataIndex: 'est_commission_cents',
|
||||
width: 84,
|
||||
render: (v: number | null) => (v == null ? '-' : yuan(v)),
|
||||
},
|
||||
{
|
||||
title: '退款佣金',
|
||||
dataIndex: 'refund_profit_cents',
|
||||
width: 84,
|
||||
render: (v: number | null) =>
|
||||
v == null ? '-' : v ? <span style={{ color: '#cf1322' }}>{yuan(v)}</span> : yuan(0),
|
||||
},
|
||||
{
|
||||
title: '净佣金',
|
||||
key: 'net',
|
||||
width: 84,
|
||||
render: (_, r) =>
|
||||
r.est_commission_cents == null ? (
|
||||
'-'
|
||||
) : (
|
||||
<b>{yuan(r.est_commission_cents - (r.refund_profit_cents || 0))}</b>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结算佣金',
|
||||
dataIndex: 'settled_commission_cents',
|
||||
width: 84,
|
||||
render: (v: number | null) => (v == null ? '-' : <span style={{ color: '#3f8600' }}>{yuan(v)}</span>),
|
||||
},
|
||||
];
|
||||
|
||||
const wxColumns: ColumnsType<WxUser> = [
|
||||
{
|
||||
title: '用户',
|
||||
key: 'user',
|
||||
render: (_, u) => (
|
||||
<Space>
|
||||
{u.headimgurl ? (
|
||||
<img
|
||||
src={u.headimgurl}
|
||||
alt=""
|
||||
style={{ width: 32, height: 32, borderRadius: '50%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{ width: 32, height: 32, borderRadius: '50%', background: '#f0f0f0', display: 'inline-block' }}
|
||||
/>
|
||||
)}
|
||||
<span>{u.nickname || '(未授权昵称)'}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '领券次数',
|
||||
dataIndex: 'copy_count',
|
||||
width: 90,
|
||||
sorter: (a, b) => a.copy_count - b.copy_count,
|
||||
render: (v: number) => (v ? <b style={{ color: '#ff4d4f' }}>{v}</b> : 0),
|
||||
},
|
||||
{ title: '点击次数', dataIndex: 'visit_count', width: 90 },
|
||||
{ title: '首次进入', dataIndex: 'first_seen', width: 160, render: (v: string) => formatUtcTime(v) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }} align="center" wrap>
|
||||
<Button onClick={() => router.push('/cps')}>← 返回</Button>
|
||||
<span style={{ fontSize: 18, fontWeight: 600 }}>
|
||||
群「{data?.group_name ?? ''}」点击趋势
|
||||
</span>
|
||||
</Space>
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Segmented options={RANGES} value={range} onChange={(v) => setRange(v as string)} />
|
||||
</Card>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
{totals.map((t) => (
|
||||
<Col key={t.name} xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title={`${t.name}(合计)`} value={t.sum} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Card>
|
||||
<Spin spinning={loading}>
|
||||
{hasData ? (
|
||||
<Line {...config} />
|
||||
) : (
|
||||
<Empty
|
||||
style={{ padding: '80px 0' }}
|
||||
description={loading ? '加载中…' : '该时间范围内暂无点击数据'}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
{tableDays != null && (
|
||||
<Card title={`每日明细(近 ${tableDays} 天)`} style={{ marginTop: 16 }}>
|
||||
{daily && !daily.is_meituan && (
|
||||
<div style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>
|
||||
该群非美团渠道,订单/佣金类无对账数据,显示「-」
|
||||
</div>
|
||||
)}
|
||||
<Table<DailyRow>
|
||||
rowKey="date"
|
||||
size="small"
|
||||
columns={dailyColumns}
|
||||
dataSource={daily?.rows ?? []}
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title={`群内微信用户(${wxUsers.length})`} style={{ marginTop: 16 }}>
|
||||
<div style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>
|
||||
在微信里打开本群落地页并授权的用户。「领券次数」= 点了几次「复制口令」;昵称/头像需用户点领券时授权补充。
|
||||
</div>
|
||||
<Table<WxUser>
|
||||
rowKey="openid"
|
||||
size="small"
|
||||
columns={wxColumns}
|
||||
dataSource={wxUsers}
|
||||
pagination={false}
|
||||
scroll={{ x: 560 }}
|
||||
locale={{ emptyText: '暂无授权用户' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,963 @@
|
||||
'use client';
|
||||
|
||||
// CPS 优惠券分发与对账。平台:美团(actId+sid,完整对账) / 淘宝(淘口令) / 京东(链接)。
|
||||
// 淘宝/京东无 API → 只统计点击,对账字段显示 "-"。单页 Tabs:对账统计/群管理/活动管理/订单明细。
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { formatUtcTime, yuan } from '@/lib/format';
|
||||
import { usePagedList } from '@/lib/usePagedList';
|
||||
import type {
|
||||
CpsActivity,
|
||||
CpsGroup,
|
||||
CpsGroupStat,
|
||||
CpsOrder,
|
||||
CpsReconcileResult,
|
||||
CpsReferralLinks,
|
||||
CpsStats,
|
||||
} from '@/lib/types';
|
||||
|
||||
const MT_STATUS: Record<string, { text: string; color: string }> = {
|
||||
'2': { text: '已付款', color: 'blue' },
|
||||
'3': { text: '已完成', color: 'cyan' },
|
||||
'4': { text: '已取消', color: 'red' },
|
||||
'5': { text: '风控', color: 'orange' },
|
||||
'6': { text: '已结算', color: 'green' },
|
||||
};
|
||||
|
||||
const PLATFORM_LABEL: Record<string, string> = { meituan: '美团', taobao: '淘宝', jd: '京东' };
|
||||
const PLATFORM_COLOR: Record<string, string> = { meituan: 'gold', taobao: 'volcano', jd: 'red' };
|
||||
const PLATFORM_OPTIONS = [
|
||||
{ label: '美团', value: 'meituan' },
|
||||
{ label: '淘宝', value: 'taobao' },
|
||||
{ label: '京东', value: 'jd' },
|
||||
];
|
||||
|
||||
const fmtRate = (r: string | null) => (r ? `${Number(r) / 100}%` : '-');
|
||||
const DASH = <span style={{ color: '#bbb' }}>-</span>;
|
||||
const platformTags = (ps: string[] | undefined) =>
|
||||
(ps || []).map((p) => (
|
||||
<Tag key={p} color={PLATFORM_COLOR[p]} style={{ marginInlineEnd: 4 }}>
|
||||
{PLATFORM_LABEL[p] || p}
|
||||
</Tag>
|
||||
));
|
||||
|
||||
export default function CpsPage() {
|
||||
return (
|
||||
<div>
|
||||
<h2>CPS 优惠券分发与对账</h2>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
|
||||
美团(actId + sid,可完整对账下单/佣金) / 淘宝(淘口令) / 京东(链接)。淘宝、京东无开放 API,
|
||||
<b>只统计点击</b>,对账字段显示「-」。发群链接都是咱的落地页 /c/xxx。
|
||||
</Typography.Paragraph>
|
||||
<Tabs
|
||||
defaultActiveKey="stats"
|
||||
destroyInactiveTabPane
|
||||
items={[
|
||||
{ key: 'stats', label: '对账统计', children: <StatsTab /> },
|
||||
{ key: 'groups', label: '群管理', children: <GroupsTab /> },
|
||||
{ key: 'activities', label: '活动管理', children: <ActivitiesTab /> },
|
||||
{ key: 'orders', label: '订单明细', children: <OrdersTab /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────── 对账统计 ─────────────
|
||||
function StatsTab() {
|
||||
const [days, setDays] = useState(30);
|
||||
const [data, setData] = useState<CpsStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reconciling, setReconciling] = useState(false);
|
||||
const canReconcile = canDo(['finance']);
|
||||
|
||||
const load = useCallback(async (d: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await api.get<CpsStats>('/admin/api/cps/stats', { params: { days: d } });
|
||||
setData(resp.data);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(days);
|
||||
}, [days, load]);
|
||||
|
||||
const reconcile = async () => {
|
||||
setReconciling(true);
|
||||
try {
|
||||
const resp = await api.post<CpsReconcileResult>('/admin/api/cps/orders/reconcile', null, {
|
||||
params: { days },
|
||||
});
|
||||
const r = resp.data;
|
||||
message.success(`对账完成:拉取 ${r.fetched} 单(新增 ${r.inserted}、更新 ${r.updated})`);
|
||||
load(days);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setReconciling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<CpsGroupStat> = [
|
||||
{
|
||||
title: '群',
|
||||
dataIndex: 'name',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
fixed: 'left',
|
||||
render: (v: string, r) =>
|
||||
r.group_id ? <Link href={`/cps/groups/${r.group_id}`}>{v}</Link> : v,
|
||||
},
|
||||
{ title: '平台', key: 'platforms', width: 130, render: (_, r) => platformTags(r.platforms) },
|
||||
{ title: '人数', dataIndex: 'member_count', width: 64, render: (v) => v ?? '-' },
|
||||
{ title: '点击', dataIndex: 'click_pv', width: 64 },
|
||||
{ title: '独立', dataIndex: 'click_uv', width: 64 },
|
||||
{
|
||||
title: '复制',
|
||||
dataIndex: 'copy_pv',
|
||||
width: 64,
|
||||
render: (v: number) => (v ? v : DASH),
|
||||
},
|
||||
{
|
||||
title: '有效单',
|
||||
dataIndex: 'order_count',
|
||||
width: 70,
|
||||
render: (v: number | null) => (v == null ? DASH : v),
|
||||
},
|
||||
{
|
||||
title: '取消/风控',
|
||||
dataIndex: 'canceled_count',
|
||||
width: 80,
|
||||
render: (v: number | null) => (v == null ? DASH : v ? <span style={{ color: '#cf1322' }}>{v}</span> : 0),
|
||||
},
|
||||
{
|
||||
title: '点击→下单',
|
||||
key: 'rate',
|
||||
width: 88,
|
||||
render: (_, r) =>
|
||||
r.order_count == null ? DASH : r.click_uv ? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%` : '-',
|
||||
},
|
||||
{
|
||||
title: '成交额',
|
||||
dataIndex: 'gmv_cents',
|
||||
width: 96,
|
||||
render: (v: number | null) => (v == null ? DASH : yuan(v)),
|
||||
},
|
||||
{
|
||||
title: '预估佣金',
|
||||
dataIndex: 'est_commission_cents',
|
||||
width: 100,
|
||||
sorter: (a, b) => (a.est_commission_cents || 0) - (b.est_commission_cents || 0),
|
||||
render: (v: number | null) => (v == null ? DASH : <b>{yuan(v)}</b>),
|
||||
},
|
||||
{
|
||||
title: '已结算佣金',
|
||||
dataIndex: 'settled_commission_cents',
|
||||
width: 100,
|
||||
render: (v: number | null) => (v == null ? DASH : <span style={{ color: '#3f8600' }}>{yuan(v)}</span>),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<span>统计区间</span>
|
||||
<Select
|
||||
value={days}
|
||||
onChange={setDays}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 7, label: '近 7 天' },
|
||||
{ value: 30, label: '近 30 天' },
|
||||
{ value: 90, label: '近 90 天' },
|
||||
]}
|
||||
/>
|
||||
{canReconcile && (
|
||||
<Button type="primary" loading={reconciling} onClick={reconcile}>
|
||||
刷新对账(拉美团订单)
|
||||
</Button>
|
||||
)}
|
||||
<Typography.Text type="secondary">淘宝/京东无对账,「-」即无法对账;佣金以「已结算」为准</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Space size="large" style={{ marginBottom: 16 }} wrap>
|
||||
<Card size="small">
|
||||
<Statistic title="有效订单数(美团)" value={data?.total_order_count ?? 0} />
|
||||
</Card>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="预估佣金"
|
||||
value={(data?.total_est_commission_cents ?? 0) / 100}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
/>
|
||||
</Card>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="已结算佣金"
|
||||
value={(data?.total_settled_commission_cents ?? 0) / 100}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey={(r) => r.sid ?? `gid-${r.group_id ?? 'none'}`}
|
||||
columns={columns}
|
||||
dataSource={data?.groups ?? []}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────── 群管理 ─────────────
|
||||
function GroupsTab() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [applied, setApplied] = useState<Record<string, unknown>>({});
|
||||
const { items, total, page, pageSize, loading, onChange, reload } = usePagedList<CpsGroup>(
|
||||
'/admin/api/cps/groups',
|
||||
applied,
|
||||
);
|
||||
const canManage = canDo(['operator']);
|
||||
|
||||
const [editing, setEditing] = useState<CpsGroup | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [linkGroup, setLinkGroup] = useState<CpsGroup | null>(null);
|
||||
|
||||
const delGroup = async (g: CpsGroup) => {
|
||||
try {
|
||||
await api.delete(`/admin/api/cps/groups/${g.id}`);
|
||||
message.success('已删除群');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<CpsGroup> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '群名', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{ title: '平台', key: 'platforms', width: 140, render: (_, g) => platformTags(g.platforms) },
|
||||
{
|
||||
title: 'sid',
|
||||
dataIndex: 'sid',
|
||||
width: 130,
|
||||
render: (v: string | null) => (v ? <Typography.Text copyable>{v}</Typography.Text> : DASH),
|
||||
},
|
||||
{ title: '人数', dataIndex: 'member_count', width: 70, render: (v) => v ?? '-' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s}</Tag>,
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'op',
|
||||
fixed: 'right',
|
||||
width: 200,
|
||||
render: (_, g) => (
|
||||
<Space onClick={(e) => e.stopPropagation()}>
|
||||
{canManage && <a onClick={() => setLinkGroup(g)}>生成链接</a>}
|
||||
{canManage && <a onClick={() => setEditing(g)}>编辑</a>}
|
||||
{canManage && (
|
||||
<Popconfirm
|
||||
title="删除这个群?"
|
||||
description="已发出的链接仍可用,但该群不再计入统计。"
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => delGroup(g)}
|
||||
>
|
||||
<a style={{ color: '#cf1322' }}>删除</a>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Input
|
||||
placeholder="群名/sid"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={() => setApplied({ keyword: keyword || undefined })}
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Button type="primary" onClick={() => setApplied({ keyword: keyword || undefined })}>
|
||||
查询
|
||||
</Button>
|
||||
{canManage && <Button onClick={() => setCreateOpen(true)}>新建群</Button>}
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 个群`,
|
||||
onChange,
|
||||
}}
|
||||
scroll={{ x: 1090 }}
|
||||
/>
|
||||
|
||||
<GroupFormModal open={createOpen} group={null} onClose={() => setCreateOpen(false)} onDone={reload} />
|
||||
<GroupFormModal open={!!editing} group={editing} onClose={() => setEditing(null)} onDone={reload} />
|
||||
<ReferralLinkModal group={linkGroup} onClose={() => setLinkGroup(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupFormModal({
|
||||
open,
|
||||
group,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
open: boolean;
|
||||
group: CpsGroup | null;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!group;
|
||||
const watchPlatforms = (Form.useWatch('platforms', form) as string[] | undefined) || [];
|
||||
const hasMeituan = watchPlatforms.includes('meituan');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (group) {
|
||||
form.setFieldsValue({
|
||||
name: group.name,
|
||||
platforms: group.platforms,
|
||||
member_count: group.member_count,
|
||||
remark: group.remark,
|
||||
status: group.status,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ platforms: ['meituan'] });
|
||||
}
|
||||
}
|
||||
}, [open, group, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
try {
|
||||
if (isEdit && group) {
|
||||
await api.patch(`/admin/api/cps/groups/${group.id}`, v);
|
||||
message.success('已更新群');
|
||||
} else {
|
||||
await api.post('/admin/api/cps/groups', v);
|
||||
message.success('已新建群');
|
||||
}
|
||||
onClose();
|
||||
onDone();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={isEdit ? '编辑群' : '新建群'} open={open} onOk={submit} onCancel={onClose} destroyOnHidden>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="群名" rules={[{ required: true, message: '请输入群名' }]}>
|
||||
<Input placeholder="如:宝妈优惠群1" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="platforms"
|
||||
label="这个群发哪些平台的券(可多选)"
|
||||
rules={[{ required: true, message: '至少选一个平台' }]}
|
||||
>
|
||||
<Checkbox.Group options={PLATFORM_OPTIONS} />
|
||||
</Form.Item>
|
||||
{hasMeituan && !isEdit && (
|
||||
<Form.Item
|
||||
name="sid"
|
||||
label="美团 sid(渠道标识,留空自动生成 g+ID)"
|
||||
rules={[{ pattern: /^[A-Za-z0-9]+$/, message: '仅字母和数字' }]}
|
||||
>
|
||||
<Input placeholder="留空自动生成;或自定义如 baoma1" maxLength={64} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{hasMeituan && isEdit && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
|
||||
当前 sid:{group?.sid || '(无,保存后如缺会在生成美团链接时报错)'}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Form.Item name="member_count" label="群人数(用于估算转化率)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} placeholder="如 200" />
|
||||
</Form.Item>
|
||||
{isEdit && (
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'active', label: 'active(启用)' },
|
||||
{ value: 'archived', label: 'archived(归档)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={256} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferralLinkModal({ group, onClose }: { group: CpsGroup | null; onClose: () => void }) {
|
||||
const [activities, setActivities] = useState<CpsActivity[]>([]);
|
||||
const [activityIds, setActivityIds] = useState<number[]>([]);
|
||||
const [result, setResult] = useState<CpsReferralLinks | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) {
|
||||
setActivityIds([]);
|
||||
setResult(null);
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get('/admin/api/cps/activities', { params: { status: 'active', limit: 100 } })
|
||||
.then((r) => setActivities(r.data.items as CpsActivity[]))
|
||||
.catch((e) => message.error(errMsg(e)));
|
||||
}, [group]);
|
||||
|
||||
// 只列群平台范围内的活动
|
||||
const groupActivities = activities.filter((a) => (group?.platforms || []).includes(a.platform));
|
||||
|
||||
const generate = async () => {
|
||||
if (!group || !activityIds.length) {
|
||||
message.warning('请勾选要生成的活动');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await api.post<CpsReferralLinks>('/admin/api/cps/referral-links', {
|
||||
group_id: group.id,
|
||||
activity_ids: activityIds,
|
||||
});
|
||||
setResult(resp.data);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`生成发群链接 - ${group?.name ?? ''}`}
|
||||
open={!!group}
|
||||
onCancel={onClose}
|
||||
onOk={generate}
|
||||
okText="生成"
|
||||
confirmLoading={loading}
|
||||
destroyOnHidden
|
||||
width={660}
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
|
||||
勾选要发的活动,每个生成一条咱的落地页链接;复制后发到群里。
|
||||
</Typography.Paragraph>
|
||||
{groupActivities.length === 0 ? (
|
||||
<Typography.Text type="warning">
|
||||
该群平台({(group?.platforms || []).map((p) => PLATFORM_LABEL[p]).join('/')})下没有活动,请先到「活动管理」新建。
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Checkbox.Group
|
||||
value={activityIds}
|
||||
onChange={(v) => setActivityIds(v as number[])}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{groupActivities.map((a) => (
|
||||
<Checkbox key={a.id} value={a.id}>
|
||||
<Tag color={PLATFORM_COLOR[a.platform]}>{PLATFORM_LABEL[a.platform]}</Tag>
|
||||
{a.name}
|
||||
{a.act_id ? <span style={{ color: '#999' }}> (actId={a.act_id})</span> : null}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Typography.Text strong>👇 生成结果(逐条复制发群):</Typography.Text>
|
||||
{result.results.map((item) => (
|
||||
<div
|
||||
key={item.code}
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 10,
|
||||
background: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Tag color={PLATFORM_COLOR[item.platform]}>{PLATFORM_LABEL[item.platform]}</Tag>
|
||||
<span style={{ fontSize: 13 }}>{item.activity_name}</span>
|
||||
</div>
|
||||
<Typography.Text copyable={{ text: item.redirect_url }} style={{ fontSize: 14, wordBreak: 'break-all' }}>
|
||||
{item.redirect_url}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
{result.results.some((i) => !i.redirect_url.startsWith('http')) && (
|
||||
<Typography.Text type="warning" style={{ fontSize: 12 }}>
|
||||
⚠️ 部分为相对路径:后端未配 CPS_REDIRECT_BASE,请配跳转域名后重启。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────── 活动管理 ─────────────
|
||||
function ActivitiesTab() {
|
||||
const { items, total, page, pageSize, loading, onChange, reload } = usePagedList<CpsActivity>(
|
||||
'/admin/api/cps/activities',
|
||||
{},
|
||||
);
|
||||
const canManage = canDo(['operator']);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<CpsActivity | null>(null);
|
||||
|
||||
const delActivity = async (a: CpsActivity) => {
|
||||
try {
|
||||
await api.delete(`/admin/api/cps/activities/${a.id}`);
|
||||
message.success('已删除活动');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<CpsActivity> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{
|
||||
title: '平台',
|
||||
dataIndex: 'platform',
|
||||
width: 80,
|
||||
render: (v: string) => <Tag color={PLATFORM_COLOR[v]}>{PLATFORM_LABEL[v] || v}</Tag>,
|
||||
},
|
||||
{ title: '活动名', dataIndex: 'name', width: 180, ellipsis: true },
|
||||
{
|
||||
title: 'actId(美团)',
|
||||
dataIndex: 'act_id',
|
||||
width: 110,
|
||||
render: (v: string | null) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '淘口令/链接',
|
||||
dataIndex: 'payload',
|
||||
width: 240,
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '落地页图',
|
||||
dataIndex: 'image_url',
|
||||
width: 80,
|
||||
render: (v: string | null) =>
|
||||
v ? (
|
||||
<img src={v} alt="" style={{ width: 36, height: 46, objectFit: 'cover', borderRadius: 4 }} />
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s}</Tag>,
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) },
|
||||
...(canManage
|
||||
? ([
|
||||
{
|
||||
title: '操作',
|
||||
key: 'op',
|
||||
fixed: 'right' as const,
|
||||
width: 120,
|
||||
render: (_: unknown, a: CpsActivity) => (
|
||||
<Space>
|
||||
<a onClick={() => setEditing(a)}>编辑</a>
|
||||
<Popconfirm
|
||||
title="删除这个活动?"
|
||||
description="已生成的链接仍可用;淘宝活动删除后其落地页改用默认图。"
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => delActivity(a)}
|
||||
>
|
||||
<a style={{ color: '#cf1322' }}>删除</a>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
] as ColumnsType<CpsActivity>)
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canManage && (
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
新建活动
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 个活动`,
|
||||
onChange,
|
||||
}}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
<ActivityFormModal open={createOpen} activity={null} onClose={() => setCreateOpen(false)} onDone={reload} />
|
||||
<ActivityFormModal open={!!editing} activity={editing} onClose={() => setEditing(null)} onDone={reload} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 淘宝活动落地页图选择器:上传新图 或 点选已有图。受控组件(value = image_url 绝对 URL)。
|
||||
function ImagePicker({ value, onChange }: { value?: string | null; onChange?: (v: string) => void }) {
|
||||
const [existing, setExisting] = useState<string[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<{ images: string[] }>('/admin/api/cps/activity-images')
|
||||
.then((r) => setExisting(r.data.images || []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const upload = async (file: File) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const r = await api.post<{ url: string }>('/admin/api/cps/upload-image', fd);
|
||||
onChange?.(r.data.url);
|
||||
setExisting((prev) => (prev.includes(r.data.url) ? prev : [r.data.url, ...prev]));
|
||||
message.success('图片已上传');
|
||||
} catch (e) {
|
||||
message.error(errMsg(e, '上传失败'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const thumb = (u: string, w: number, selected: boolean) => (
|
||||
<img
|
||||
key={u}
|
||||
src={u}
|
||||
alt="落地页图"
|
||||
onClick={() => onChange?.(u)}
|
||||
style={{
|
||||
width: w,
|
||||
height: Math.round(w * 1.27),
|
||||
objectFit: 'cover',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
border: selected ? '2px solid #ff4d4f' : '2px solid #f0f0f0',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => {
|
||||
upload(file);
|
||||
return false; // 阻止 antd 默认上传,走我们自己的端点
|
||||
}}
|
||||
>
|
||||
<Button loading={uploading}>上传新图</Button>
|
||||
</Upload>
|
||||
{value && (
|
||||
<div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>当前:</span>
|
||||
{thumb(value, 90, true)}
|
||||
</div>
|
||||
)}
|
||||
{existing.length > 0 && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ fontSize: 12, color: '#999', marginBottom: 6 }}>或选择已有图:</div>
|
||||
<Space wrap>{existing.map((u) => thumb(u, 56, u === value))}</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityFormModal({
|
||||
open,
|
||||
activity,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
open: boolean;
|
||||
activity: CpsActivity | null;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!activity;
|
||||
const platform = (Form.useWatch('platform', form) as string | undefined) || 'meituan';
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (activity) {
|
||||
form.setFieldsValue({
|
||||
name: activity.name,
|
||||
platform: activity.platform,
|
||||
act_id: activity.act_id,
|
||||
product_view_sign: activity.product_view_sign,
|
||||
payload: activity.payload,
|
||||
image_url: activity.image_url,
|
||||
remark: activity.remark,
|
||||
status: activity.status,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ platform: 'meituan' });
|
||||
}
|
||||
}
|
||||
}, [open, activity, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
if (v.platform === 'meituan') {
|
||||
if (!v.act_id && !v.product_view_sign) {
|
||||
message.warning('美团活动:actId 与 productViewSign 至少填一个');
|
||||
return;
|
||||
}
|
||||
} else if (!v.payload) {
|
||||
message.warning(v.platform === 'taobao' ? '请填淘口令' : '请填京东链接');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (isEdit && activity) {
|
||||
await api.patch(`/admin/api/cps/activities/${activity.id}`, v);
|
||||
message.success('已更新活动');
|
||||
} else {
|
||||
await api.post('/admin/api/cps/activities', v);
|
||||
message.success('已新建活动');
|
||||
}
|
||||
onClose();
|
||||
onDone();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={isEdit ? '编辑活动' : '新建活动'} open={open} onOk={submit} onCancel={onClose} destroyOnHidden>
|
||||
<Form form={form} layout="vertical" initialValues={{ platform: 'meituan' }}>
|
||||
<Form.Item name="name" label="活动名" rules={[{ required: true }]}>
|
||||
<Input placeholder="如:点我领取大额红包" />
|
||||
</Form.Item>
|
||||
<Form.Item name="platform" label="平台" rules={[{ required: true }]}>
|
||||
<Select options={PLATFORM_OPTIONS} />
|
||||
</Form.Item>
|
||||
|
||||
{platform === 'meituan' && (
|
||||
<>
|
||||
<Form.Item name="act_id" label="actId(美团活动物料 ID,推荐)">
|
||||
<Input placeholder="美团联盟「我要推广-活动推广」第一列 ID,如 792" maxLength={64} />
|
||||
</Form.Item>
|
||||
<Form.Item name="product_view_sign" label="productViewSign(商品券,与 actId 二选一)">
|
||||
<Input maxLength={128} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{platform === 'taobao' && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="payload"
|
||||
label="淘口令(整段,运营从淘宝联盟复制,落地页原样复制给用户)"
|
||||
rules={[{ required: true, message: '请填淘口令' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="如:8¥abcXYZ¥ https://m.tb.cn/h.xxx 长按复制本条..." maxLength={4096} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="image_url"
|
||||
label="落地页图(用户点链接后看到的页面主图,上传新图或选已有)"
|
||||
rules={[{ required: true, message: '请上传或选择落地页图' }]}
|
||||
>
|
||||
<ImagePicker />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{platform === 'jd' && (
|
||||
<Form.Item
|
||||
name="payload"
|
||||
label="京东推广链接(落地页 302 跳到它)"
|
||||
rules={[{ required: true, message: '请填京东链接' }]}
|
||||
>
|
||||
<Input placeholder="如:https://u.jd.com/xxxxxx" maxLength={1024} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={256} />
|
||||
</Form.Item>
|
||||
{isEdit && (
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'active', label: '启用(active)' },
|
||||
{ value: 'archived', label: '归档(archived)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────── 订单明细 ─────────────
|
||||
function OrdersTab() {
|
||||
const [sid, setSid] = useState('');
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [applied, setApplied] = useState<Record<string, unknown>>({});
|
||||
const { items, total, page, pageSize, loading, onChange } = usePagedList<CpsOrder>(
|
||||
'/admin/api/cps/orders',
|
||||
applied,
|
||||
);
|
||||
|
||||
const columns: ColumnsType<CpsOrder> = [
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'order_id',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (v: string) => <Typography.Text copyable>{v}</Typography.Text>,
|
||||
},
|
||||
{ title: 'sid', dataIndex: 'sid', width: 120, render: (v) => v || '-' },
|
||||
{ title: '商品', dataIndex: 'product_name', width: 200, ellipsis: true, render: (v) => v || '-' },
|
||||
{
|
||||
title: '成交额',
|
||||
dataIndex: 'pay_price_cents',
|
||||
width: 100,
|
||||
render: (v: number | null) => (v != null ? yuan(v) : '-'),
|
||||
},
|
||||
{
|
||||
title: '佣金',
|
||||
dataIndex: 'commission_cents',
|
||||
width: 90,
|
||||
render: (v: number | null) => (v != null ? yuan(v) : '-'),
|
||||
},
|
||||
{ title: '佣金率', dataIndex: 'commission_rate', width: 80, render: fmtRate },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'mt_status',
|
||||
width: 90,
|
||||
render: (s: string | null) => {
|
||||
const m = s ? MT_STATUS[s] : null;
|
||||
return m ? <Tag color={m.color}>{m.text}</Tag> : s || '-';
|
||||
},
|
||||
},
|
||||
{ title: '支付时间', dataIndex: 'pay_time', width: 150, render: (v) => formatUtcTime(v) },
|
||||
];
|
||||
|
||||
const search = () => setApplied({ sid: sid || undefined, mt_status: status });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Input
|
||||
placeholder="sid"
|
||||
value={sid}
|
||||
onChange={(e) => setSid(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={Object.entries(MT_STATUS).map(([v, m]) => ({ value: v, label: m.text }))}
|
||||
/>
|
||||
<Button type="primary" onClick={search}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 单`,
|
||||
onChange,
|
||||
}}
|
||||
scroll={{ x: 1030 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
Spin,
|
||||
Switch,
|
||||
Table,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
|
||||
@@ -51,8 +51,10 @@ const yuan = (cents: number) => (cents / 100).toFixed(2);
|
||||
|
||||
/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「数据大盘」页的一个区块。 */
|
||||
export default function HomeMarqueeSeeds() {
|
||||
const { message } = App.useApp();
|
||||
const [seeds, setSeeds] = useState<Seed[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]); // 批量操作选中行
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Seed | null>(null);
|
||||
@@ -70,6 +72,8 @@ export default function HomeMarqueeSeeds() {
|
||||
try {
|
||||
const { data } = await api.get<Seed[]>('/admin/api/marquee-seeds');
|
||||
setSeeds(data);
|
||||
// 列表刷新后清掉已不存在的选中 id(单行删除/外部变更后防悬空,顶部计数与批量操作才准)
|
||||
setSelectedRowKeys((prev) => prev.filter((k) => data.some((s) => s.id === k)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -175,6 +179,33 @@ export default function HomeMarqueeSeeds() {
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 批量操作(对选中多行)=====
|
||||
const batchDelete = async () => {
|
||||
try {
|
||||
const { data } = await api.post<{ deleted: number }>('/admin/api/marquee-seeds/batch-delete', {
|
||||
ids: selectedRowKeys,
|
||||
});
|
||||
message.success(`已删除 ${data.deleted} 条`);
|
||||
setSelectedRowKeys([]);
|
||||
load();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
};
|
||||
const batchSetEnabled = async (enabled: boolean) => {
|
||||
try {
|
||||
const { data } = await api.post<{ updated: number }>('/admin/api/marquee-seeds/batch-enable', {
|
||||
ids: selectedRowKeys,
|
||||
enabled,
|
||||
});
|
||||
message.success(`已${enabled ? '启用' : '停用'} ${data.updated} 条`);
|
||||
setSelectedRowKeys([]);
|
||||
load();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户名',
|
||||
@@ -229,7 +260,7 @@ export default function HomeMarqueeSeeds() {
|
||||
首页「用户xxx 比价后节省xx元」轮播的兜底假数据。真实比价记录不足时随机抽取这些补齐;停用的不参与混播。
|
||||
用户名留空则每次展示随机合成;金额为区间则每次随机取值。
|
||||
</p>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Space style={{ marginBottom: 12 }} wrap>
|
||||
<Button type="primary" size="small" onClick={openAdd}>
|
||||
新增种子
|
||||
</Button>
|
||||
@@ -239,11 +270,34 @@ export default function HomeMarqueeSeeds() {
|
||||
<Button size="small" onClick={openPreview}>
|
||||
预览效果
|
||||
</Button>
|
||||
<span style={{ borderLeft: '1px solid #eee', height: 18 }} />
|
||||
<Button size="small" disabled={!selectedRowKeys.length} onClick={() => batchSetEnabled(true)}>
|
||||
批量启用
|
||||
</Button>
|
||||
<Button size="small" disabled={!selectedRowKeys.length} onClick={() => batchSetEnabled(false)}>
|
||||
批量停用
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确认删除选中的 ${selectedRowKeys.length} 条种子?`}
|
||||
onConfirm={batchDelete}
|
||||
disabled={!selectedRowKeys.length}
|
||||
>
|
||||
<Button size="small" danger disabled={!selectedRowKeys.length}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
{selectedRowKeys.length > 0 && (
|
||||
<span style={{ color: '#999', fontSize: 12 }}>已选 {selectedRowKeys.length} 条</span>
|
||||
)}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys.map(Number)),
|
||||
}}
|
||||
columns={columns}
|
||||
dataSource={seeds}
|
||||
pagination={false}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
@@ -15,9 +16,9 @@ import {
|
||||
Spin,
|
||||
Tag,
|
||||
Tooltip,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
|
||||
interface StatItem {
|
||||
metric: string; // help_users / total_compares / total_saved
|
||||
@@ -32,7 +33,7 @@ interface StatItem {
|
||||
random_kind: 'mult' | 'add'; // 自增长方式:×倍率 / +绝对增量
|
||||
random_step_min: number; // 绝对增量区间(基础单位)
|
||||
random_step_max: number;
|
||||
real_offset: number; // 真实值基数偏移(基础单位)
|
||||
real_offset: number; // 真实值保底值(基础单位):展示=max(真实,保底)。沿用 real_offset 列名(语义已改)
|
||||
allow_decrease: boolean; // 是否允许展示值下降
|
||||
random_current: number | null;
|
||||
random_last_tick_at: string | null;
|
||||
@@ -54,7 +55,7 @@ function decomposeInterval(sec: number): { num: number; unit: IntervalUnit } {
|
||||
interface Edit {
|
||||
mode: 'real' | 'manual' | 'random';
|
||||
manual: number | null; // 展示单位
|
||||
realOffset: number; // 真实值偏移(展示单位)
|
||||
realOffset: number; // 真实值保底值(展示单位)
|
||||
growthKind: 'mult' | 'add'; // 自增长方式
|
||||
multMin: number; // 1.0 ~
|
||||
multMax: number;
|
||||
@@ -127,7 +128,7 @@ function predictNext(it: StatItem): string | null {
|
||||
const hi = Math.floor((cur * it.random_mult_max) / 1000);
|
||||
return `${d(lo)} ~ ${d(hi)} ${u}`;
|
||||
}
|
||||
const target = it.mode === 'real' ? it.real_value + it.real_offset : it.manual_value ?? cur;
|
||||
const target = it.mode === 'real' ? Math.max(it.real_value, it.real_offset) : it.manual_value ?? cur;
|
||||
const eff = !it.allow_decrease && target < cur ? cur : target;
|
||||
return `${it.mode === 'real' ? '≈ ' : ''}${d(eff)} ${u}`;
|
||||
}
|
||||
@@ -150,8 +151,104 @@ function nextRefreshLabel(tickSeconds: number, anchorMinutes: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 三者关系「智能校验」(劝阻式,非硬拦)=====
|
||||
// 触发阈值比「理想区间」宽,只在明显离谱时弹窗;理想区间在弹窗里作为建议展示。
|
||||
const REL_PER_USER = { min: 2, max: 20, ideal: '4~8 次/人' }; // 人均比价 = 完成比价 / 帮助用户
|
||||
const REL_PER_COMPARE = { min: 2, max: 30, ideal: '5~12 元/次' }; // 每次省额 = 累计节省(元) / 完成比价
|
||||
|
||||
function fmtNum(n: number): string {
|
||||
return n >= 10000 ? `${(n / 10000).toFixed(2).replace(/\.?0+$/, '')}万` : String(Math.round(n));
|
||||
}
|
||||
|
||||
// 只增不减护栏的前端镜像:目标低于现值且未允许下降时,实际仍显示现值。
|
||||
function guardedBase(allowDecrease: boolean, current: number | null, target: number | null): number | null {
|
||||
if (target == null) return current;
|
||||
if (!allowDecrease && current != null && target < current) return current;
|
||||
return target;
|
||||
}
|
||||
|
||||
// 本次保存后,该指标「大概会显示」的值(基础单位:total_saved 为分)。
|
||||
function intendedBase(it: StatItem, e: Edit): number | null {
|
||||
const cur = it.random_current;
|
||||
if (e.mode === 'manual') {
|
||||
const t = e.manual != null ? (dispToBase(it.metric, e.manual) as number) : cur;
|
||||
return guardedBase(e.allowDecrease, cur, t);
|
||||
}
|
||||
if (e.mode === 'real') {
|
||||
const t = Math.max(it.real_value, (dispToBase(it.metric, e.realOffset) as number) ?? 0);
|
||||
return guardedBase(e.allowDecrease, cur, t);
|
||||
}
|
||||
// random:填了初始基数=设起点(过护栏);留空=保持现展示值
|
||||
if (e.initial != null) return guardedBase(e.allowDecrease, cur, dispToBase(it.metric, e.initial) as number);
|
||||
return cur;
|
||||
}
|
||||
|
||||
// 校验三者关系。返回 null=数据不全无法判断(跳过校验);否则 warnings 为空=合理。
|
||||
// 被改的指标用「本次要设的目标值」,另两个用各自当前展示值 random_current。
|
||||
function checkRelations(
|
||||
items: StatItem[],
|
||||
edits: Record<string, Edit>,
|
||||
): { warnings: string[]; users: number; compares: number; savedYuan: number } | null {
|
||||
const valBase = (metric: string): number | null => {
|
||||
const it = items.find((x) => x.metric === metric);
|
||||
const e = it ? edits[metric] : undefined;
|
||||
return it && e ? intendedBase(it, e) : null;
|
||||
};
|
||||
const users = valBase('help_users');
|
||||
const compares = valBase('total_compares');
|
||||
const savedBase = valBase('total_saved');
|
||||
if (users == null || compares == null || savedBase == null) return null;
|
||||
if (users <= 0 || compares <= 0 || savedBase <= 0) return null;
|
||||
|
||||
const savedYuan = savedBase / 100;
|
||||
const warnings: string[] = [];
|
||||
if (compares < users) {
|
||||
warnings.push(`完成比价(${fmtNum(compares)})少于帮助用户(${fmtNum(users)}),正常应「次数 ≥ 人数」`);
|
||||
}
|
||||
const perUser = compares / users;
|
||||
if (perUser < REL_PER_USER.min || perUser > REL_PER_USER.max) {
|
||||
warnings.push(
|
||||
`人均比价 ${perUser.toFixed(1)} 次/人,${perUser < REL_PER_USER.min ? '偏低' : '偏高'}(建议 ${REL_PER_USER.ideal})`,
|
||||
);
|
||||
}
|
||||
const perCompare = savedYuan / compares;
|
||||
if (perCompare < REL_PER_COMPARE.min || perCompare > REL_PER_COMPARE.max) {
|
||||
warnings.push(
|
||||
`每次省 ${perCompare.toFixed(1)} 元/次,${perCompare < REL_PER_COMPARE.min ? '偏低' : '偏高'}(建议 ${REL_PER_COMPARE.ideal})`,
|
||||
);
|
||||
}
|
||||
return { warnings, users, compares, savedYuan };
|
||||
}
|
||||
|
||||
// 把某指标的编辑态打包成 PATCH body(三项共用,逐个指标各打一份)。
|
||||
function buildBody(metric: string, e: Edit, immediate: boolean): Record<string, number | string | boolean> {
|
||||
// 自定义不暴露间隔,固定为每天(每日「更新时间」刷新一次);其余模式用所选间隔。
|
||||
const intervalSec =
|
||||
e.mode === 'manual'
|
||||
? 86400
|
||||
: Math.max(60, Math.round(e.intervalNum) * UNIT_SECONDS[e.intervalUnit]);
|
||||
const anchorMin = e.anchorHour * 60 + e.anchorMinute; // 更新时间(全天 HH:MM),后端按间隔取相位
|
||||
const body: Record<string, number | string | boolean> = {
|
||||
mode: e.mode,
|
||||
random_mult_min: Math.round(e.multMin * 1000),
|
||||
random_mult_max: Math.round(e.multMax * 1000),
|
||||
random_tick_seconds: intervalSec,
|
||||
random_anchor_minutes: anchorMin,
|
||||
random_kind: e.growthKind,
|
||||
random_step_min: (dispToBase(metric, e.stepMin) as number) ?? 0,
|
||||
random_step_max: (dispToBase(metric, e.stepMax) as number) ?? 0,
|
||||
real_offset: (dispToBase(metric, e.realOffset) as number) ?? 0,
|
||||
allow_decrease: e.allowDecrease,
|
||||
};
|
||||
if (e.manual != null) body.manual_value = dispToBase(metric, e.manual) as number;
|
||||
if (e.initial != null) body.random_initial = dispToBase(metric, e.initial) as number;
|
||||
if (immediate) body.apply_now = true;
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 首页三门面数字(帮助用户/完成比价/累计节省)的展示模式配置。作为「数据大盘」页的一个区块。 */
|
||||
export default function HomeStatsConfig() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [items, setItems] = useState<StatItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [edits, setEdits] = useState<Record<string, Edit>>({});
|
||||
@@ -188,52 +285,67 @@ export default function HomeStatsConfig() {
|
||||
const patch = (metric: string, p: Partial<Edit>) =>
|
||||
setEdits((s) => ({ ...s, [metric]: { ...s[metric], ...p } }));
|
||||
|
||||
// immediate=true:点「立即更新」,保存配置 + 马上刷新展示值(不等更新钟点)。
|
||||
const save = async (it: StatItem, immediate = false) => {
|
||||
const e = edits[it.metric];
|
||||
// 自定义不暴露间隔,固定为每天(每日「更新时间」刷新一次);其余模式用所选间隔。
|
||||
const intervalSec =
|
||||
e.mode === 'manual'
|
||||
? 86400
|
||||
: Math.max(60, Math.round(e.intervalNum) * UNIT_SECONDS[e.intervalUnit]);
|
||||
const anchorMin = e.anchorHour * 60 + e.anchorMinute; // 更新时间(全天 HH:MM),后端按间隔取相位
|
||||
const body: Record<string, number | string | boolean> = {
|
||||
mode: e.mode,
|
||||
random_mult_min: Math.round(e.multMin * 1000),
|
||||
random_mult_max: Math.round(e.multMax * 1000),
|
||||
random_tick_seconds: intervalSec,
|
||||
random_anchor_minutes: anchorMin,
|
||||
random_kind: e.growthKind,
|
||||
random_step_min: (dispToBase(it.metric, e.stepMin) as number) ?? 0,
|
||||
random_step_max: (dispToBase(it.metric, e.stepMax) as number) ?? 0,
|
||||
real_offset: (dispToBase(it.metric, e.realOffset) as number) ?? 0,
|
||||
allow_decrease: e.allowDecrease,
|
||||
};
|
||||
if (e.manual != null) body.manual_value = dispToBase(it.metric, e.manual) as number;
|
||||
if (e.initial != null) body.random_initial = dispToBase(it.metric, e.initial) as number;
|
||||
if (immediate) body.apply_now = true;
|
||||
|
||||
setSaving(it.metric);
|
||||
try {
|
||||
// PATCH 返回更新后的该指标;只就地替换这一项 + 重置它的输入态,不整页 reload(消除闪烁)。
|
||||
const { data } = await api.patch<StatItem>(`/admin/api/dashboard-display/${it.metric}`, body);
|
||||
setItems((prev) => prev.map((x) => (x.metric === it.metric ? data : x)));
|
||||
setEdits((s) => ({ ...s, [it.metric]: toEdit(data) }));
|
||||
if (immediate) {
|
||||
message.success('已立即更新,客户端下次进首页即可见');
|
||||
} else {
|
||||
const lastTickMs = data.random_last_tick_at ? new Date(data.random_last_tick_at).getTime() : 0;
|
||||
const effectiveNow = Date.now() - lastTickMs < 10_000;
|
||||
// 三项共享:一次保存 / 立即更新全部三个指标(各打一份 PATCH)。
|
||||
// 校验三者最终关系,不合理则弹窗劝阻、确认才提交。
|
||||
const saveAll = async (immediate = false) => {
|
||||
const proceed = async () => {
|
||||
setSaving('__all__');
|
||||
// 逐个 PATCH(顺序提交,各自 commit 一行)。results 在 try 外声明,半失败时也能把已成功的合并进 state。
|
||||
const results: StatItem[] = [];
|
||||
try {
|
||||
for (const it of items) {
|
||||
const { data } = await api.patch<StatItem>(
|
||||
`/admin/api/dashboard-display/${it.metric}`,
|
||||
buildBody(it.metric, edits[it.metric], immediate),
|
||||
);
|
||||
results.push(data);
|
||||
}
|
||||
message.success(
|
||||
effectiveNow
|
||||
? '已保存,客户端下次进首页即可见'
|
||||
: `已保存,展示值将于北京时间 ${nextRefreshLabel(data.random_tick_seconds, data.random_anchor_minutes)} 刷新后生效`,
|
||||
immediate
|
||||
? '三项已立即更新,客户端下次进首页即可见'
|
||||
: '三项配置已保存,展示值将按各自「更新时间」刷新后生效(自定义/立即更新即时生效)',
|
||||
);
|
||||
} catch (err) {
|
||||
// 前 results.length 项后端已 commit,失败的是第 results.length+1 项
|
||||
message.error(`第 ${results.length + 1} 项保存失败(前 ${results.length} 项已成功): ${errMsg(err)}`);
|
||||
} finally {
|
||||
// 半失败也把已成功的就地合并进 state,避免前端显旧值与后端撕裂
|
||||
if (results.length > 0) {
|
||||
setItems((prev) => prev.map((x) => results.find((r) => r.metric === x.metric) ?? x));
|
||||
setEdits((s) => ({ ...s, ...Object.fromEntries(results.map((d) => [d.metric, toEdit(d)])) }));
|
||||
}
|
||||
setSaving(null);
|
||||
}
|
||||
} catch (err) {
|
||||
message.error(errMsg(err));
|
||||
} finally {
|
||||
setSaving(null);
|
||||
};
|
||||
|
||||
// 智能校验:按下后三者大概会呈现的关系是否合理,不合理则弹窗劝阻、确认才提交。
|
||||
const rel = checkRelations(items, edits);
|
||||
if (rel && rel.warnings.length > 0) {
|
||||
modal.confirm({
|
||||
title: '数据关系校验',
|
||||
width: 480,
|
||||
okText: '仍要这样设置',
|
||||
cancelText: '返回修改',
|
||||
content: (
|
||||
<div style={{ fontSize: 13 }}>
|
||||
<div style={{ marginBottom: 8 }}>检测到三项门面数据关系不太真实:</div>
|
||||
<ul style={{ paddingLeft: 18, margin: '0 0 8px' }}>
|
||||
{rel.warnings.map((w, i) => (
|
||||
<li key={i} style={{ color: '#d4380d', marginBottom: 2 }}>
|
||||
{w}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ color: '#888' }}>
|
||||
当前预计:帮助用户 {fmtNum(rel.users)} · 完成比价 {fmtNum(rel.compares)} · 累计节省{' '}
|
||||
{fmtNum(rel.savedYuan)}元
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onOk: proceed,
|
||||
});
|
||||
} else {
|
||||
await proceed();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -241,7 +353,7 @@ export default function HomeStatsConfig() {
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h3 style={{ marginBottom: 4 }}>首页数据配置</h3>
|
||||
<p style={{ color: '#999', marginTop: 0 }}>
|
||||
客户端首页三个门面数字。每个指标独立选模式:<b>真实值</b>(实时查库,可加基数偏移)/{' '}
|
||||
客户端首页三个门面数字。每个指标独立选模式:<b>真实值</b>(实时查库,可设保底值)/{' '}
|
||||
<b>自定义</b>(固定值)/ <b>自增长</b>(到对齐钟点按倍率或固定增量增长)。
|
||||
默认<b>只增不减</b>(防门面缩水);每次改动进审计日志。
|
||||
</p>
|
||||
@@ -260,6 +372,20 @@ export default function HomeStatsConfig() {
|
||||
e.manual != null &&
|
||||
it.random_current != null &&
|
||||
(dispToBase(it.metric, e.manual) as number) < it.random_current;
|
||||
// real 同理:max(真实值,保底值) 低于当前展示值且未允许下降 → 立即更新会被只增不减护栏挡住
|
||||
const realTargetBase = Math.max(it.real_value, (dispToBase(it.metric, e.realOffset) as number) ?? 0);
|
||||
const realBlocked =
|
||||
e.mode === 'real' &&
|
||||
!e.allowDecrease &&
|
||||
it.random_current != null &&
|
||||
realTargetBase < it.random_current;
|
||||
// 自增长:填的初始基数低于当前展示值且未允许下降 → 会被只增不减护栏挡住(保持现值)
|
||||
const initialBlocked =
|
||||
e.mode === 'random' &&
|
||||
!e.allowDecrease &&
|
||||
e.initial != null &&
|
||||
it.random_current != null &&
|
||||
(dispToBase(it.metric, e.initial) as number) < it.random_current;
|
||||
return (
|
||||
<Col key={it.metric} xs={24} md={12} xl={8}>
|
||||
<Card
|
||||
@@ -307,19 +433,26 @@ export default function HomeStatsConfig() {
|
||||
)}
|
||||
|
||||
{e.mode === 'real' && (
|
||||
<Space wrap>
|
||||
<span>基数偏移:</span>
|
||||
<InputNumber
|
||||
value={e.realOffset}
|
||||
min={0}
|
||||
onChange={(v) => patch(it.metric, { realOffset: v ?? 0 })}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<span style={{ color: '#666' }}>{u}</span>
|
||||
<Tooltip title="展示值 = 真实值 + 此基数。冷启动期真实数字小,加个体面的基数,且仍随真实增长。">
|
||||
<span style={{ color: '#999', cursor: 'help' }}>ⓘ</span>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<div>
|
||||
<Space wrap>
|
||||
<span>保底值:</span>
|
||||
<InputNumber
|
||||
value={e.realOffset}
|
||||
min={0}
|
||||
onChange={(v) => patch(it.metric, { realOffset: v ?? 0 })}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<span style={{ color: '#666' }}>{u}</span>
|
||||
<Tooltip title="展示值 = max(真实值, 保底值)。冷启动期真实数字小,显示保底撑场面;真实值涨过保底后显示纯真实、零差距。留 0 = 纯真实值。">
|
||||
<span style={{ color: '#999', cursor: 'help' }}>ⓘ</span>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
{realBlocked && (
|
||||
<div style={{ color: '#fa8c16', fontSize: 12, marginTop: 4 }}>
|
||||
真实值与保底取大后仍低于当前展示值,默认不下调(如需下调请勾「允许数字下降」)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{e.mode === 'random' && (
|
||||
@@ -431,17 +564,26 @@ export default function HomeStatsConfig() {
|
||||
</Space>
|
||||
|
||||
{e.mode === 'random' && (
|
||||
<Space wrap>
|
||||
<span>初始基数:</span>
|
||||
<InputNumber
|
||||
value={e.initial}
|
||||
min={0}
|
||||
onChange={(v) => patch(it.metric, { initial: v })}
|
||||
style={{ width: 200 }}
|
||||
placeholder="留空=用真实值播种"
|
||||
/>
|
||||
<span style={{ color: '#666' }}>{u}</span>
|
||||
</Space>
|
||||
<div>
|
||||
<Space wrap>
|
||||
<span>初始基数:</span>
|
||||
<InputNumber
|
||||
value={e.initial}
|
||||
min={0}
|
||||
onChange={(v) => patch(it.metric, { initial: v })}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<span style={{ color: '#666' }}>{u}</span>
|
||||
<Tooltip title="填值=把展示值设为该数作为增长起点(受『只增不减』限制:低于当前展示值需先勾「允许数字下降」)。留空=保持现有展示值不变、继续按增量增长;仅当该指标从未启用过(还没有展示值)时,留空才用真实值播种。">
|
||||
<span style={{ color: '#999', cursor: 'help' }}>ⓘ</span>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
{initialBlocked && (
|
||||
<div style={{ color: '#fa8c16', fontSize: 12, marginTop: 4 }}>
|
||||
低于当前展示值,默认不下调(如需下调请勾「允许数字下降」)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
@@ -460,7 +602,7 @@ export default function HomeStatsConfig() {
|
||||
</span>
|
||||
{it.updated_at && (
|
||||
<span style={{ color: '#bbb' }}>
|
||||
更新于 {new Date(it.updated_at).toLocaleString('zh-CN')}
|
||||
更新于 {formatUtcTime(it.updated_at, 'YYYY-MM-DD HH:mm:ss')}
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
@@ -474,21 +616,10 @@ export default function HomeStatsConfig() {
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Button type="primary" loading={saving === it.metric} onClick={() => save(it)}>
|
||||
保存
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="立即更新会马上推进一次(自增长走一档),不等更新时间。确定?"
|
||||
onConfirm={() => save(it, true)}
|
||||
>
|
||||
<Button loading={saving === it.metric}>立即更新</Button>
|
||||
</Popconfirm>
|
||||
<Tag color={it.mode === 'real' ? 'green' : it.mode === 'manual' ? 'orange' : 'blue'}>
|
||||
线上当前:
|
||||
{it.mode === 'real' ? '真实值' : it.mode === 'manual' ? '自定义' : '自增长'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Tag color={it.mode === 'real' ? 'green' : it.mode === 'manual' ? 'orange' : 'blue'}>
|
||||
线上当前:
|
||||
{it.mode === 'real' ? '真实值' : it.mode === 'manual' ? '自定义' : '自增长'}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
@@ -496,6 +627,22 @@ export default function HomeStatsConfig() {
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
{!loading && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Space>
|
||||
<Button type="primary" loading={saving != null} onClick={() => saveAll(false)}>
|
||||
保存
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="立即更新会马上把三项都推进一次(自增长各走一档),不等更新时间。确定?"
|
||||
onConfirm={() => saveAll(true)}
|
||||
>
|
||||
<Button loading={saving != null}>立即更新</Button>
|
||||
</Popconfirm>
|
||||
<span style={{ color: '#999', fontSize: 12 }}>三项配置共用,一次保存全部生效</span>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { App, Button, Popconfirm, Space, Table } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
import { useCursorList } from '@/lib/useCursorList';
|
||||
import type { DeviceOnboardingItem } from '@/lib/types';
|
||||
|
||||
// completed_at 为 UTC 口径(server_default now()),按北京显示(勿用 new Date().toLocaleString)
|
||||
const dt = (v: string) => formatUtcTime(v);
|
||||
const EMPTY: Record<string, unknown> = {};
|
||||
|
||||
export default function DevicesPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
// 后端按设备聚合、全量返回(next_cursor 恒 null),故无筛选/加载更多。
|
||||
const { items, loading, reload } = useCursorList<DeviceOnboardingItem>(
|
||||
'/admin/api/onboarding/devices',
|
||||
EMPTY,
|
||||
);
|
||||
const canReset = canDo(['operator']);
|
||||
|
||||
const resetDevice = async (d: DeviceOnboardingItem) => {
|
||||
try {
|
||||
await api.post(`/admin/api/onboarding/devices/${encodeURIComponent(d.device_id)}/reset`);
|
||||
message.success('已重置:该设备所有账号下次打开 App 重走引导');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
const resetAll = () => {
|
||||
modal.confirm({
|
||||
title: '确认全部重设新手引导?',
|
||||
content:
|
||||
'清空所有设备的引导完成记录,库里所有用户下次打开 App 都会重走一遍新手引导。此操作不可逆。',
|
||||
okText: '确认全部重设',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.post('/admin/api/onboarding/reset-all', { confirm: true });
|
||||
message.success('已全部重设:所有用户下次打开 App 会重走引导');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnsType<DeviceOnboardingItem> = [
|
||||
{ title: '设备(ANDROID_ID)', dataIndex: 'device_id' },
|
||||
{ title: '走过引导的账号数', dataIndex: 'account_count', width: 160 },
|
||||
{ title: '最近完成引导', dataIndex: 'last_completed_at', render: dt, width: 200 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'op',
|
||||
width: 120,
|
||||
render: (_: unknown, d: DeviceOnboardingItem) =>
|
||||
canReset ? (
|
||||
<Popconfirm
|
||||
title="重走引导?"
|
||||
description="该设备上所有账号下次打开 App 会重看一次。"
|
||||
onConfirm={() => resetDevice(d)}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<a>重走引导</a>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<span style={{ color: '#ccc' }}>-</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>设备管理</h2>
|
||||
<p style={{ color: '#888', marginBottom: 16 }}>
|
||||
列出走过新手引导的设备(按硬件 ANDROID_ID 聚合)。「重走引导」删除该设备的引导完成记录,
|
||||
使其上所有账号下次打开 App 重走一遍。没走过引导的设备本就会引导,不在此列。
|
||||
</p>
|
||||
{canReset && (
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" danger onClick={resetAll}>
|
||||
全部重设新手引导
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
<Table
|
||||
rowKey="device_id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
// 反馈审核抽屉:展示本条反馈 + 该用户历史反馈(就近参考),待审核态可采纳(发金币)或拒绝(填用户可见原因)。
|
||||
// 已审核态只读回显。对接同事 PR#66 接口:POST .../approve、POST .../reject(.../handle 已废弃)。
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Drawer,
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
InputNumber,
|
||||
Segmented,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
import { mediaUrl } from '@/lib/media';
|
||||
import type { CursorPage, Feedback } from '@/lib/types';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
const REWARD_MAX = 10000; // 对齐后端 FEEDBACK_REWARD_MAX_COINS
|
||||
|
||||
interface Props {
|
||||
feedback: Feedback | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onDone: () => void; // 审核成功后刷新列表
|
||||
}
|
||||
|
||||
// 待审核(同事后端初始态 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>;
|
||||
};
|
||||
|
||||
function FeedbackImages({ images }: { images: string[] | null }) {
|
||||
if (!images || !images.length) return <Text type="secondary">无截图</Text>;
|
||||
return (
|
||||
<Image.PreviewGroup>
|
||||
<Space size={4} wrap>
|
||||
{images.map((src, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
src={mediaUrl(src)}
|
||||
width={48}
|
||||
height={48}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FeedbackHandleDrawer({ feedback, open, onClose, onDone }: Props) {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
const [action, setAction] = useState<'approve' | 'reject'>('approve');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [history, setHistory] = useState<Feedback[]>([]);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
|
||||
const editable = feedback != null && isPending(feedback.status) && canDo(['operator']);
|
||||
|
||||
// 打开时:重置表单 + 拉该用户全部历史反馈(含本条,渲染时滤掉本条)
|
||||
useEffect(() => {
|
||||
if (!open || !feedback) return;
|
||||
setAction('approve');
|
||||
form.resetFields();
|
||||
let alive = true;
|
||||
setLoadingHistory(true);
|
||||
api
|
||||
.get<CursorPage<Feedback>>('/admin/api/feedbacks', {
|
||||
params: { user_id: feedback.user_id, limit: 50, sort_by: 'id', sort_order: 'desc' },
|
||||
})
|
||||
.then((r) => {
|
||||
if (alive) setHistory(r.data.items);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setHistory([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoadingHistory(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, feedback?.id]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!feedback) return;
|
||||
const v = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (action === 'approve') {
|
||||
await api.post(`/admin/api/feedbacks/${feedback.id}/approve`, {
|
||||
reward_coins: v.reward_coins,
|
||||
note: v.note?.trim() || null,
|
||||
});
|
||||
message.success(`已采纳,发放 ${v.reward_coins} 金币`);
|
||||
} else {
|
||||
await api.post(`/admin/api/feedbacks/${feedback.id}/reject`, {
|
||||
reason: v.reason.trim(),
|
||||
note: v.note?.trim() || null,
|
||||
});
|
||||
message.success('已拒绝');
|
||||
}
|
||||
onClose();
|
||||
onDone();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const others = feedback ? history.filter((h) => h.id !== feedback.id) : [];
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={feedback ? (editable ? `审核反馈 #${feedback.id}` : `反馈详情 #${feedback.id}`) : ''}
|
||||
width={560}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
destroyOnHidden
|
||||
extra={
|
||||
editable ? (
|
||||
<Button type="primary" danger={action === 'reject'} loading={submitting} onClick={submit}>
|
||||
{action === 'approve' ? '采纳并发金币' : '确认拒绝'}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{feedback && (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* 本条反馈 */}
|
||||
<div>
|
||||
<Text type="secondary">
|
||||
用户 {feedback.user_id} · {formatUtcTime(feedback.created_at)}
|
||||
</Text>
|
||||
<Paragraph style={{ whiteSpace: 'pre-wrap', marginTop: 8, marginBottom: 8 }}>
|
||||
{feedback.content || '-'}
|
||||
</Paragraph>
|
||||
<FeedbackImages images={feedback.images} />
|
||||
</div>
|
||||
|
||||
{/* 已审核(或无权限):只读回显 */}
|
||||
{!editable && (
|
||||
<div
|
||||
style={{
|
||||
background:
|
||||
feedback.status === 'adopted'
|
||||
? '#f6ffed'
|
||||
: feedback.status === 'rejected'
|
||||
? '#fff1f0'
|
||||
: '#fafafa',
|
||||
border: `1px solid ${
|
||||
feedback.status === 'adopted'
|
||||
? '#b7eb8f'
|
||||
: feedback.status === 'rejected'
|
||||
? '#ffccc7'
|
||||
: '#f0f0f0'
|
||||
}`,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
状态:{statusTag(feedback.status)}
|
||||
{feedback.reviewed_at ? (
|
||||
<Text type="secondary"> · {formatUtcTime(feedback.reviewed_at)}</Text>
|
||||
) : null}
|
||||
</div>
|
||||
{feedback.status === 'adopted' && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
奖励金币:<b>{feedback.reward_coins ?? 0}</b>
|
||||
</div>
|
||||
)}
|
||||
{feedback.status === 'rejected' && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
未采纳原因(用户可见):
|
||||
{feedback.reject_reason || <Text type="secondary">(无)</Text>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
审核备注:{feedback.review_note || <Text type="secondary">(无)</Text>}
|
||||
</div>
|
||||
{isPending(feedback.status) && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<Text type="secondary">仅 operator / super_admin 可审核</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 待审核:采纳 / 拒绝表单 */}
|
||||
{editable && (
|
||||
<div>
|
||||
<Segmented
|
||||
value={action}
|
||||
onChange={(v) => setAction(v as 'approve' | 'reject')}
|
||||
options={[
|
||||
{ label: '采纳(发金币)', value: 'approve' },
|
||||
{ label: '拒绝', value: 'reject' },
|
||||
]}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
{/* preserve=false:切换采纳/拒绝时卸载的字段从 store 移除,validateFields 不误校验另一侧 */}
|
||||
<Form form={form} layout="vertical" preserve={false}>
|
||||
{action === 'approve' ? (
|
||||
<>
|
||||
<Form.Item
|
||||
name="reward_coins"
|
||||
label={`奖励金币(必填,1 ~ ${REWARD_MAX})`}
|
||||
rules={[
|
||||
{ required: true, message: '请输入奖励金币' },
|
||||
{ type: 'number', min: 1, max: REWARD_MAX, message: `1 ~ ${REWARD_MAX}` },
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={1}
|
||||
max={REWARD_MAX}
|
||||
placeholder="采纳后发放给用户的金币"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="note"
|
||||
label="采纳要点 / 审核备注(选填)"
|
||||
rules={[{ max: 256, message: '最多 256 字' }]}
|
||||
>
|
||||
<Input.TextArea rows={3} maxLength={256} showCount placeholder="如:建议已采纳,将在下个版本上线" />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
name="reason"
|
||||
label="未采纳原因(必填,用户端可见)"
|
||||
rules={[
|
||||
{ required: true, message: '请填写未采纳原因' },
|
||||
{ max: 256, message: '最多 256 字' },
|
||||
]}
|
||||
>
|
||||
<Input.TextArea rows={3} maxLength={256} showCount placeholder="向用户说明为何未采纳" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="note"
|
||||
label="内部备注(选填,用户不可见)"
|
||||
rules={[{ max: 256, message: '最多 256 字' }]}
|
||||
>
|
||||
<Input.TextArea rows={2} maxLength={256} showCount placeholder="运营内部备注" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 该用户历史反馈 */}
|
||||
<div>
|
||||
<Text strong>该用户历史反馈{others.length ? `(${others.length})` : ''}</Text>
|
||||
{loadingHistory ? (
|
||||
<Spin style={{ display: 'block', margin: '16px 0' }} />
|
||||
) : others.length === 0 ? (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary">该用户暂无其它反馈</Text>
|
||||
</div>
|
||||
) : (
|
||||
<Space direction="vertical" size={8} style={{ width: '100%', marginTop: 8 }}>
|
||||
{others.map((h) => (
|
||||
<div
|
||||
key={h.id}
|
||||
style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 10 }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Text type="secondary">
|
||||
#{h.id} · {formatUtcTime(h.created_at)}
|
||||
</Text>
|
||||
{statusTag(h.status)}
|
||||
</div>
|
||||
<Paragraph
|
||||
style={{ whiteSpace: 'pre-wrap', margin: '6px 0 0' }}
|
||||
ellipsis={{ rows: 2, expandable: true, symbol: '展开' }}
|
||||
>
|
||||
{h.content || '-'}
|
||||
</Paragraph>
|
||||
{h.status === 'adopted' && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
采纳 · 奖励 {h.reward_coins ?? 0} 金币
|
||||
{h.review_note ? ` · ${h.review_note}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
{h.status === 'rejected' && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
未采纳:{h.reject_reason || '-'}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Image, Input, Space, Spin, Switch, Tag, Typography, Upload, message } from 'antd';
|
||||
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { mediaUrl } from '@/lib/media';
|
||||
import type { FeedbackQrConfig as QrConfig } from '@/lib/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ACCEPT = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
/** 反馈页「加群二维码」卡配置。改完 App 意见反馈页下次进入即同步。「反馈工单」页的一个区块。 */
|
||||
export default function FeedbackQrConfig() {
|
||||
const [cfg, setCfg] = useState<QrConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// 本地编辑态(文案 + 开关),保存时一次性 PATCH
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [title, setTitle] = useState('');
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [subtitle, setSubtitle] = useState('');
|
||||
|
||||
const canEdit = canDo(['operator']);
|
||||
|
||||
const sync = (c: QrConfig) => {
|
||||
setCfg(c);
|
||||
setEnabled(c.enabled);
|
||||
setTitle(c.title);
|
||||
setGroupName(c.group_name);
|
||||
setSubtitle(c.subtitle);
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<QrConfig>('/admin/api/feedback-config');
|
||||
sync(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
if (!title.trim() || !groupName.trim() || !subtitle.trim()) {
|
||||
message.warning('三行文案都需填写');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const { data } = await api.patch<QrConfig>('/admin/api/feedback-config', {
|
||||
enabled,
|
||||
title: title.trim(),
|
||||
group_name: groupName.trim(),
|
||||
subtitle: subtitle.trim(),
|
||||
});
|
||||
sync(data);
|
||||
message.success('已保存,App 反馈页下次进入即可见');
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// antd Upload:beforeUpload 里自行 POST(multipart),返回 false 阻止其默认上传。
|
||||
const beforeUpload = (file: File) => {
|
||||
if (!ACCEPT.includes(file.type)) {
|
||||
message.error('仅支持 JPEG / PNG / WebP 图片');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
message.error('图片不能超过 5MB');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
void uploadImage(file);
|
||||
return false;
|
||||
};
|
||||
|
||||
const uploadImage = async (file: File) => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
setUploading(true);
|
||||
try {
|
||||
const { data } = await api.post<QrConfig>('/admin/api/feedback-config/image', form);
|
||||
sync(data);
|
||||
message.success('二维码已更新,App 反馈页下次进入即可见');
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = async () => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const { data } = await api.delete<QrConfig>('/admin/api/feedback-config/image');
|
||||
sync(data);
|
||||
message.success('已移除二维码,App 将显示本地兜底图');
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title="反馈页二维码卡(App 意见反馈页底部)"
|
||||
style={{ marginBottom: 16 }}
|
||||
extra={
|
||||
cfg?.updated_at ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
更新于 {new Date(cfg.updated_at).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<p style={{ color: '#999', marginTop: 0 }}>
|
||||
改这里的二维码、文案、显隐,App 意见反馈页底部那张「加群二维码」卡会同步(用户下次进入反馈页生效)。
|
||||
每次改动进审计日志。
|
||||
</p>
|
||||
{loading || !cfg ? (
|
||||
<Spin style={{ display: 'block', margin: '24px 0' }} />
|
||||
) : (
|
||||
<Space align="start" size={32} wrap>
|
||||
{/* 左:所见即所得预览(对齐 App 卡片布局) */}
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
App 实际效果预览
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
width: 320,
|
||||
display: 'flex',
|
||||
gap: 14,
|
||||
alignItems: 'center',
|
||||
padding: 14,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 14,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
|
||||
opacity: enabled ? 1 : 0.4,
|
||||
}}
|
||||
>
|
||||
{cfg.image_url ? (
|
||||
<Image
|
||||
src={mediaUrl(cfg.image_url)}
|
||||
width={88}
|
||||
height={88}
|
||||
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid #f0f0f0' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 88,
|
||||
height: 88,
|
||||
borderRadius: 8,
|
||||
border: '1px dashed #d9d9d9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#bbb',
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
本地兜底图
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 13, color: '#1a1a1a', lineHeight: '21px' }}>
|
||||
<div>{title || '(标题)'}</div>
|
||||
<div>
|
||||
直通
|
||||
<b>「{groupName || '群名'}」</b>
|
||||
</div>
|
||||
<div>{subtitle || '(标语)'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{!enabled && (
|
||||
<div style={{ color: '#fa8c16', fontSize: 12, marginTop: 6 }}>
|
||||
当前为「隐藏」:App 反馈页不显示这张卡
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右:编辑控件 */}
|
||||
<Space direction="vertical" size="middle" style={{ minWidth: 320 }}>
|
||||
<Space>
|
||||
<span>显示该卡片:</span>
|
||||
<Switch checked={enabled} disabled={!canEdit} onChange={setEnabled} />
|
||||
{!enabled && <Tag color="orange">已隐藏</Tag>}
|
||||
</Space>
|
||||
|
||||
<Space wrap>
|
||||
<Upload accept="image/*" showUploadList={false} beforeUpload={beforeUpload} disabled={!canEdit}>
|
||||
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canEdit}>
|
||||
{cfg.image_url ? '更换二维码' : '上传二维码'}
|
||||
</Button>
|
||||
</Upload>
|
||||
{cfg.image_url && (
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
loading={uploading}
|
||||
disabled={!canEdit}
|
||||
onClick={removeImage}
|
||||
>
|
||||
移除二维码
|
||||
</Button>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
JPEG/PNG/WebP,≤5MB
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
第一行(操作提示)
|
||||
</Text>
|
||||
<Input
|
||||
value={title}
|
||||
maxLength={40}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="如:长按图片保存二维码"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
第二行加粗群名(App 显示为:直通「群名」)
|
||||
</Text>
|
||||
<Input
|
||||
value={groupName}
|
||||
maxLength={40}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
placeholder="如:傻瓜比价官方群"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
第三行(标语)
|
||||
</Text>
|
||||
<Input
|
||||
value={subtitle}
|
||||
maxLength={60}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setSubtitle(e.target.value)}
|
||||
placeholder="如:一起唠嗑共创、解锁新玩法"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="primary" loading={saving} disabled={!canEdit} onClick={save}>
|
||||
保存文案 / 开关
|
||||
</Button>
|
||||
{!canEdit && <Text type="secondary">仅 operator / super_admin 可修改</Text>}
|
||||
</Space>
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,54 +2,202 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Button, Select, Space, Table, Tag, message } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import type { SorterResult } from 'antd/es/table/interface';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Image,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { useCursorList } from '@/lib/useCursorList';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
import { usePagedList } from '@/lib/usePagedList';
|
||||
import type { Feedback } from '@/lib/types';
|
||||
import FeedbackQrConfig from './FeedbackQrConfig';
|
||||
import FeedbackHandleDrawer from './FeedbackHandleDrawer';
|
||||
|
||||
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
|
||||
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' },
|
||||
};
|
||||
|
||||
export default function FeedbacksPage() {
|
||||
// 筛选草稿:点「查询」才应用(避免输入即刷新)
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
||||
const { items, nextCursor, loading, loadMore, reload } = useCursorList<Feedback>(
|
||||
'/admin/api/feedbacks',
|
||||
filters,
|
||||
);
|
||||
const canHandle = canDo(['operator']);
|
||||
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 handle = async (f: Feedback) => {
|
||||
try {
|
||||
await api.post(`/admin/api/feedbacks/${f.id}/handle`);
|
||||
message.success('已标记处理');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
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 [drawerFb, setDrawerFb] = useState<Feedback | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const openDrawer = (f: Feedback) => {
|
||||
setDrawerFb(f);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
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: '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: '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: (s: string) => <Tag color={s === 'new' ? 'orange' : 'green'}>{s}</Tag>,
|
||||
render: (s: string) => {
|
||||
const m = STATUS_META[s] ?? { label: s, color: 'default' };
|
||||
return <Tag color={m.color}>{m.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '审核结果',
|
||||
key: 'review',
|
||||
width: 170,
|
||||
render: (_: unknown, 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>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
sorter: true,
|
||||
sortOrder: sortOrderOf('created_at'),
|
||||
render: (v: string) => formatUtcTime(v),
|
||||
},
|
||||
{ title: '时间', dataIndex: 'created_at', render: dt, width: 180 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'op',
|
||||
width: 100,
|
||||
width: 90,
|
||||
render: (_: unknown, f: Feedback) =>
|
||||
canHandle && f.status === 'new' ? (
|
||||
<a onClick={() => handle(f)}>标记处理</a>
|
||||
canReview && isPending(f.status) ? (
|
||||
<a onClick={() => openDrawer(f)}>审核</a>
|
||||
) : (
|
||||
<span style={{ color: '#ccc' }}>-</span>
|
||||
<a onClick={() => openDrawer(f)}>查看</a>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -57,28 +205,70 @@ export default function FeedbacksPage() {
|
||||
return (
|
||||
<div>
|
||||
<h2>反馈工单</h2>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<FeedbackQrConfig />
|
||||
<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: '已处理' },
|
||||
{ value: 'pending', label: '审核中' },
|
||||
{ value: 'adopted', label: '已采纳' },
|
||||
{ value: 'rejected', label: '未采纳' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<Table rowKey="id" columns={columns} dataSource={items} loading={loading} pagination={false} />
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||
{nextCursor ? '加载更多' : '没有更多了'}
|
||||
<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>
|
||||
</div>
|
||||
<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}
|
||||
/>
|
||||
|
||||
<FeedbackHandleDrawer
|
||||
feedback={drawerFb}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onDone={reload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
BarChartOutlined,
|
||||
DashboardOutlined,
|
||||
FileSearchOutlined,
|
||||
FlagOutlined,
|
||||
LogoutOutlined,
|
||||
MessageOutlined,
|
||||
MobileOutlined,
|
||||
MoneyCollectOutlined,
|
||||
ProfileOutlined,
|
||||
SettingOutlined,
|
||||
ShareAltOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -21,8 +26,13 @@ const { Sider, Header, Content } = Layout;
|
||||
const MENU = [
|
||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
{ key: '/devices', icon: <MobileOutlined />, label: '设备管理' },
|
||||
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
|
||||
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
|
||||
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
|
||||
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
|
||||
{ key: '/ad-revenue', icon: <BarChartOutlined />, label: '广告管理' },
|
||||
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS 分发' },
|
||||
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
|
||||
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
|
||||
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Card, Col, Descriptions, Row, Spin, Statistic, Table, Tabs, Tag } from 'antd';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Result,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { formatUtcTime, formatWallTime, yuan } from '@/lib/format';
|
||||
import { useCursorList } from '@/lib/useCursorList';
|
||||
import type { CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
|
||||
|
||||
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
|
||||
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
|
||||
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
|
||||
import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const params = useParams<{ id: string }>();
|
||||
const uid = Number(params.id);
|
||||
const [data, setData] = useState<UserOverview | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [coinOpen, setCoinOpen] = useState(false);
|
||||
const [cashOpen, setCashOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<UserOverview>(`/admin/api/users/${uid}`).then((r) => setData(r.data));
|
||||
const loadData = useCallback(() => {
|
||||
setErr(null);
|
||||
api
|
||||
.get<UserOverview>(`/admin/api/users/${uid}`)
|
||||
.then((r) => setData(r.data))
|
||||
.catch((e) => setErr(errMsg(e, '加载用户详情失败')));
|
||||
}, [uid]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const coins = useCursorList<CoinTxn>('/admin/api/wallet/coin-transactions', { user_id: uid });
|
||||
const cash = useCursorList<CashTxn>('/admin/api/wallet/cash-transactions', { user_id: uid });
|
||||
const withdraws = useCursorList<WithdrawOrder>('/admin/api/withdraws', { user_id: uid });
|
||||
|
||||
const canCoins = canDo(['finance']);
|
||||
const canStatus = canDo(['operator']);
|
||||
|
||||
const toggleStatus = () => {
|
||||
if (!data) return;
|
||||
const next = data.user.status === 'active' ? 'disabled' : 'active';
|
||||
modal.confirm({
|
||||
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${data.user.phone}?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.post(`/admin/api/users/${uid}/status`, { status: next });
|
||||
message.success('已更新状态');
|
||||
loadData();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="加载失败"
|
||||
subTitle={err}
|
||||
extra={
|
||||
<Button type="primary" onClick={loadData}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!data) return <Spin style={{ display: 'block', marginTop: 80 }} />;
|
||||
|
||||
const coinCols: ColumnsType<CoinTxn> = [
|
||||
{ title: '时间', dataIndex: 'created_at', render: dt },
|
||||
// 金币流水为北京 wall-clock 口径,原样显示
|
||||
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatWallTime(v) },
|
||||
{
|
||||
title: '变动',
|
||||
dataIndex: 'amount',
|
||||
@@ -42,17 +104,58 @@ export default function UserDetailPage() {
|
||||
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' },
|
||||
];
|
||||
|
||||
const cashCols: ColumnsType<CashTxn> = [
|
||||
// 现金流水为北京 wall-clock 口径,原样显示
|
||||
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatWallTime(v) },
|
||||
{
|
||||
title: '变动',
|
||||
dataIndex: 'amount_cents',
|
||||
render: (v: number) => (
|
||||
<span style={{ color: v > 0 ? '#3f8600' : '#cf1322' }}>
|
||||
{v > 0 ? '+' : ''}
|
||||
{yuan(v)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: '余额', dataIndex: 'balance_after_cents', render: yuan },
|
||||
{ title: '类型', dataIndex: 'biz_type' },
|
||||
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' },
|
||||
];
|
||||
|
||||
const wdCols: ColumnsType<WithdrawOrder> = [
|
||||
{ title: '时间', dataIndex: 'created_at', render: dt },
|
||||
// 提现单为 UTC 口径
|
||||
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v) },
|
||||
{ title: '金额', dataIndex: 'amount_cents', render: yuan },
|
||||
{ title: '状态', dataIndex: 'status', render: (s: string) => <Tag>{s}</Tag> },
|
||||
{ title: '单号', dataIndex: 'out_bill_no' },
|
||||
{ title: '失败原因', dataIndex: 'fail_reason', render: (v: string | null) => v || '-' },
|
||||
];
|
||||
|
||||
const loadMoreLink = (list: { loadMore: () => void; nextCursor: number | null }) => (
|
||||
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
||||
<a
|
||||
onClick={list.loadMore}
|
||||
style={{ pointerEvents: list.nextCursor ? 'auto' : 'none', opacity: list.nextCursor ? 1 : 0.4 }}
|
||||
>
|
||||
加载更多
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>用户详情 #{uid}</h2>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
|
||||
<h2 style={{ margin: 0 }}>用户详情 #{uid}</h2>
|
||||
<Space wrap>
|
||||
{canCoins && <Button onClick={() => setCoinOpen(true)}>调金币</Button>}
|
||||
{canCoins && <Button onClick={() => setCashOpen(true)}>调现金</Button>}
|
||||
{canStatus && data.user.status !== 'deleted' && (
|
||||
<Button danger={data.user.status === 'active'} onClick={toggleStatus}>
|
||||
{data.user.status === 'active' ? '封禁' : '解封'}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Descriptions column={3}>
|
||||
<Descriptions.Item label="手机号">{data.user.phone}</Descriptions.Item>
|
||||
@@ -64,7 +167,7 @@ export default function UserDetailPage() {
|
||||
<Descriptions.Item label="微信绑定">
|
||||
{data.user.wechat_openid ? '已绑定' : '未绑定'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="注册时间">{dt(data.user.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="注册时间">{formatUtcTime(data.user.created_at)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
@@ -115,11 +218,23 @@ export default function UserDetailPage() {
|
||||
loading={coins.loading}
|
||||
pagination={false}
|
||||
/>
|
||||
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
||||
<a onClick={coins.loadMore} style={{ pointerEvents: coins.nextCursor ? 'auto' : 'none', opacity: coins.nextCursor ? 1 : 0.4 }}>
|
||||
加载更多
|
||||
</a>
|
||||
</div>
|
||||
{loadMoreLink(coins)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cash',
|
||||
label: '现金流水',
|
||||
children: (
|
||||
<>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={cashCols}
|
||||
dataSource={cash.items}
|
||||
loading={cash.loading}
|
||||
pagination={false}
|
||||
/>
|
||||
{loadMoreLink(cash)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -138,6 +253,25 @@ export default function UserDetailPage() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<AdjustCoinModal
|
||||
user={data.user}
|
||||
open={coinOpen}
|
||||
onClose={() => setCoinOpen(false)}
|
||||
onDone={() => {
|
||||
loadData();
|
||||
coins.reload();
|
||||
}}
|
||||
/>
|
||||
<AdjustCashModal
|
||||
user={data.user}
|
||||
open={cashOpen}
|
||||
onClose={() => setCashOpen(false)}
|
||||
onDone={() => {
|
||||
loadData();
|
||||
cash.reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+293
-60
@@ -1,60 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Key } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { SorterResult } from 'antd/es/table/interface';
|
||||
import { App, Button, DatePicker, Input, Select, Space, Table, Tag } from 'antd';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { useCursorList } from '@/lib/useCursorList';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
import { usePagedList } from '@/lib/usePagedList';
|
||||
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
|
||||
import type { UserListItem } from '@/lib/types';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = { active: 'green', disabled: 'red', deleted: 'default' };
|
||||
|
||||
type SortField = 'id' | 'created_at' | 'last_login_at';
|
||||
|
||||
export default function UsersPage() {
|
||||
const router = useRouter();
|
||||
const { message, modal } = App.useApp();
|
||||
|
||||
// 筛选草稿:点「查询」才应用(避免输入即刷新);状态/渠道/日期一并随查询提交
|
||||
const [phone, setPhone] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
||||
const { items, nextCursor, loading, loadMore, reload } = useCursorList<UserListItem>(
|
||||
'/admin/api/users',
|
||||
filters,
|
||||
);
|
||||
const [channel, setChannel] = useState<string | undefined>();
|
||||
const [regRange, setRegRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
const [loginRange, setLoginRange] = 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, total, page, pageSize, loading, onChange: onPageChange, reload } =
|
||||
usePagedList<UserListItem>('/admin/api/users', filters);
|
||||
|
||||
const canCoins = canDo(['finance']);
|
||||
const canStatus = canDo(['operator']);
|
||||
|
||||
const [coinUser, setCoinUser] = useState<UserListItem | null>(null);
|
||||
const [coinForm] = Form.useForm();
|
||||
const [cashUser, setCashUser] = useState<UserListItem | null>(null);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||
|
||||
const search = () => setFilters({ phone: phone || undefined, status });
|
||||
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
|
||||
useEffect(() => {
|
||||
setSelectedRowKeys([]);
|
||||
}, [filterKey]);
|
||||
|
||||
const submitCoins = async () => {
|
||||
const v = await coinForm.validateFields();
|
||||
try {
|
||||
await api.post(`/admin/api/users/${coinUser!.id}/coins`, v);
|
||||
message.success('已调整金币');
|
||||
setCoinUser(null);
|
||||
coinForm.resetFields();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
const search = () =>
|
||||
setApplied({
|
||||
phone: phone || undefined,
|
||||
nickname: nickname || undefined,
|
||||
status,
|
||||
register_channel: channel || undefined,
|
||||
created_from: regRange?.[0] ? regRange[0].startOf('day').toISOString() : undefined,
|
||||
created_to: regRange?.[1] ? regRange[1].endOf('day').toISOString() : undefined,
|
||||
last_login_from: loginRange?.[0] ? loginRange[0].startOf('day').toISOString() : undefined,
|
||||
last_login_to: loginRange?.[1] ? loginRange[1].endOf('day').toISOString() : undefined,
|
||||
});
|
||||
|
||||
const resetFilters = () => {
|
||||
setPhone('');
|
||||
setNickname('');
|
||||
setStatus(undefined);
|
||||
setChannel(undefined);
|
||||
setRegRange(null);
|
||||
setLoginRange(null);
|
||||
setSortBy('id');
|
||||
setSortOrder('desc');
|
||||
setApplied({});
|
||||
};
|
||||
|
||||
const onTableChange = (
|
||||
_pagination: unknown,
|
||||
_filters: unknown,
|
||||
sorter: SorterResult<UserListItem> | SorterResult<UserListItem>[],
|
||||
) => {
|
||||
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 {
|
||||
// 取消排序 → 回默认(ID 倒序)
|
||||
setSortBy('id');
|
||||
setSortOrder('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortOrderOf = (field: SortField) =>
|
||||
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
|
||||
|
||||
const toggleStatus = (u: UserListItem) => {
|
||||
const next = u.status === 'active' ? 'disabled' : 'active';
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${u.phone}?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
@@ -68,10 +112,113 @@ export default function UsersPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDebugTrace = (u: UserListItem) => {
|
||||
const next = !u.debug_trace_enabled;
|
||||
modal.confirm({
|
||||
title: `确认${next ? '开启' : '关闭'}用户 ${u.phone} 的调试链接权限?`,
|
||||
content: next
|
||||
? '开启后,该用户在 App 比价结果页/记录页可复制 trace 调试链接发给开发排障'
|
||||
: undefined,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.post(`/admin/api/users/${u.id}/debug-trace`, { enabled: next });
|
||||
message.success('已更新调试链接权限');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 一次性操作:点一下把该用户标记为「未看过引导」,下次打开 App 重看一遍,看完后端自动恢复(只触发这一次)。
|
||||
// 用户尚未重看前可「撤销」(把 force_onboarding 置回 false),纠正误点。
|
||||
const setForceOnboarding = (u: UserListItem, enable: boolean) => {
|
||||
modal.confirm({
|
||||
title: enable
|
||||
? `确认让用户 ${u.phone} 重看一次新手引导?`
|
||||
: `撤销用户 ${u.phone} 的「重看新手引导」?`,
|
||||
content: enable
|
||||
? '点一下即把该用户标记为「未看过引导」:Ta 下次打开 App 会重走一遍新手引导,看完自动恢复——只触发这一次。仅对已支持该功能的 App 版本生效。'
|
||||
: '该用户尚未重看,撤销后下次打开 App 不再触发。',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.post(`/admin/api/users/${u.id}/force-onboarding`, { enabled: enable });
|
||||
message.success(enable ? '已开启:该用户下次打开 App 会重看一次' : '已撤销');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectedUsers = items.filter((u) => selectedRowKeys.includes(u.id));
|
||||
const selectedActive = selectedUsers.filter((u) => u.status === 'active');
|
||||
const selectedDisabled = selectedUsers.filter((u) => u.status === 'disabled');
|
||||
|
||||
// 批量:循环调现有逐用户接口(各自已带审计),Promise.allSettled 汇总成败,不新增 bulk 端点
|
||||
const runBulk = async (
|
||||
targets: UserListItem[],
|
||||
label: string,
|
||||
call: (u: UserListItem) => Promise<unknown>,
|
||||
) => {
|
||||
const results = await Promise.allSettled(targets.map(call));
|
||||
const ok = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const fail = results.length - ok;
|
||||
if (fail) message.warning(`${label}完成:成功 ${ok}/${results.length},失败 ${fail}`);
|
||||
else message.success(`${label}完成:${ok} 个`);
|
||||
setSelectedRowKeys([]);
|
||||
reload();
|
||||
};
|
||||
|
||||
const bulkStatus = (target: 'disabled' | 'active') => {
|
||||
const targets = target === 'disabled' ? selectedActive : selectedDisabled;
|
||||
const label = target === 'disabled' ? '批量封禁' : '批量解封';
|
||||
if (!targets.length) {
|
||||
message.warning(`没有可${target === 'disabled' ? '封禁(active)' : '解封(disabled)'}的选中用户`);
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: `确认${label} ${targets.length} 个用户?`,
|
||||
content: target === 'disabled' ? '仅对选中的 active 用户生效' : '仅对选中的 disabled 用户生效',
|
||||
okButtonProps: { danger: target === 'disabled' },
|
||||
onOk: () =>
|
||||
runBulk(targets, label, (u) =>
|
||||
api.post(`/admin/api/users/${u.id}/status`, { status: target }),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const bulkDebugTrace = (enabled: boolean) => {
|
||||
// 只对「非 deleted 且当前态不同」的选中用户操作,避免无谓请求
|
||||
const targets = selectedUsers.filter(
|
||||
(u) => u.status !== 'deleted' && u.debug_trace_enabled !== enabled,
|
||||
);
|
||||
const label = enabled ? '批量开调试链接' : '批量关调试链接';
|
||||
if (!targets.length) {
|
||||
message.warning('没有需要变更的选中用户');
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: `确认${label} ${targets.length} 个用户?`,
|
||||
onOk: () =>
|
||||
runBulk(targets, label, (u) =>
|
||||
api.post(`/admin/api/users/${u.id}/debug-trace`, { enabled }),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnsType<UserListItem> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '手机号', dataIndex: 'phone' },
|
||||
{ title: '昵称', dataIndex: 'nickname', render: (v: string | null) => v || '-' },
|
||||
{ title: 'ID', dataIndex: 'id', width: 80, sorter: true, sortOrder: sortOrderOf('id') },
|
||||
{ title: '手机号', dataIndex: 'phone', width: 130 },
|
||||
{
|
||||
title: '昵称',
|
||||
dataIndex: 'nickname',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => v || '-',
|
||||
},
|
||||
{ title: '渠道', dataIndex: 'register_channel', width: 90 },
|
||||
{
|
||||
title: '状态',
|
||||
@@ -82,18 +229,46 @@ export default function UsersPage() {
|
||||
{
|
||||
title: '注册时间',
|
||||
dataIndex: 'created_at',
|
||||
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
||||
width: 160,
|
||||
sorter: true,
|
||||
sortOrder: sortOrderOf('created_at'),
|
||||
render: (v: string) => formatUtcTime(v),
|
||||
},
|
||||
{
|
||||
title: '最近登录',
|
||||
dataIndex: 'last_login_at',
|
||||
width: 160,
|
||||
sorter: true,
|
||||
sortOrder: sortOrderOf('last_login_at'),
|
||||
render: (v: string) => formatUtcTime(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'op',
|
||||
fixed: 'right',
|
||||
width: 300,
|
||||
render: (_: unknown, u: UserListItem) => (
|
||||
<Space>
|
||||
<Space wrap={false} onClick={(e) => e.stopPropagation()}>
|
||||
<a onClick={() => router.push(`/users/${u.id}`)}>详情</a>
|
||||
{canCoins && <a onClick={() => setCoinUser(u)}>调金币</a>}
|
||||
{canCoins && <a onClick={() => setCashUser(u)}>调现金</a>}
|
||||
{canStatus && u.status !== 'deleted' && (
|
||||
<a onClick={() => toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'}</a>
|
||||
)}
|
||||
{canStatus && u.status !== 'deleted' && (
|
||||
<a onClick={() => toggleDebugTrace(u)}>
|
||||
{u.debug_trace_enabled ? '关调试链接' : '开调试链接'}
|
||||
</a>
|
||||
)}
|
||||
{canStatus &&
|
||||
u.status !== 'deleted' &&
|
||||
(u.force_onboarding ? (
|
||||
<span style={{ color: '#fa8c16' }}>
|
||||
待重看 <a onClick={() => setForceOnboarding(u, false)}>撤销</a>
|
||||
</span>
|
||||
) : (
|
||||
<a onClick={() => setForceOnboarding(u, true)}>开启新手引导</a>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -109,47 +284,105 @@ export default function UsersPage() {
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="昵称(模糊)"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ value: 'active', label: 'active' },
|
||||
{ value: 'disabled', label: 'disabled' },
|
||||
{ value: 'deleted', label: 'deleted' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
placeholder="渠道"
|
||||
value={channel}
|
||||
onChange={setChannel}
|
||||
allowClear
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ value: 'sms', label: 'sms' },
|
||||
{ value: 'wechat', label: 'wechat' },
|
||||
]}
|
||||
/>
|
||||
<RangePicker
|
||||
placeholder={['注册起', '注册止']}
|
||||
value={regRange}
|
||||
onChange={(v) => setRegRange(v as [Dayjs, Dayjs] | null)}
|
||||
allowClear
|
||||
/>
|
||||
<RangePicker
|
||||
placeholder={['最近登录起', '最近登录止']}
|
||||
value={loginRange}
|
||||
onChange={(v) => setLoginRange(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} />
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||
{nextCursor ? '加载更多' : '没有更多了'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={`调整金币 - ${coinUser?.phone ?? ''}`}
|
||||
open={!!coinUser}
|
||||
onOk={submitCoins}
|
||||
onCancel={() => setCoinUser(null)}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={coinForm} layout="vertical">
|
||||
<Form.Item name="amount" label="金币变动(正=增加,负=扣减)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
{canStatus && selectedUsers.length > 0 && (
|
||||
<Space style={{ marginBottom: 12, width: '100%' }} wrap>
|
||||
<span style={{ color: '#888' }}>
|
||||
已选 {selectedUsers.length} 个(active {selectedActive.length}、disabled{' '}
|
||||
{selectedDisabled.length})
|
||||
</span>
|
||||
<Button danger disabled={!selectedActive.length} onClick={() => bulkStatus('disabled')}>
|
||||
批量封禁
|
||||
</Button>
|
||||
<Button disabled={!selectedDisabled.length} onClick={() => bulkStatus('active')}>
|
||||
批量解封
|
||||
</Button>
|
||||
<Button onClick={() => bulkDebugTrace(true)}>批量开调试链接</Button>
|
||||
<Button onClick={() => bulkDebugTrace(false)}>批量关调试链接</Button>
|
||||
<Button type="link" onClick={() => setSelectedRowKeys([])}>
|
||||
清空选择
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
rowSelection={
|
||||
canStatus
|
||||
? {
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
getCheckboxProps: (u) => ({ disabled: u.status === 'deleted' }),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 个用户`,
|
||||
onChange: onPageChange,
|
||||
}}
|
||||
scroll={{ x: 1160 }}
|
||||
onChange={onTableChange}
|
||||
/>
|
||||
|
||||
<AdjustCoinModal user={coinUser} open={!!coinUser} onClose={() => setCoinUser(null)} />
|
||||
<AdjustCashModal user={cashUser} open={!!cashUser} onClose={() => setCashUser(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Radio,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import type { CursorPage, UserCoinRecord, UserRewardStats, WithdrawUserSnapshot } from '@/lib/types';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
|
||||
// 后端时间为 UTC(无时区串补 Z),按北京时区展示(与提现列表口径一致)
|
||||
const apiTime = (v: string) => {
|
||||
const hasTz = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v);
|
||||
return dayjs(hasTz ? v : `${v}Z`);
|
||||
};
|
||||
const dt = (v: string) => apiTime(v).format('YYYY-MM-DD HH:mm');
|
||||
|
||||
const SOURCE_COLOR: Record<string, string> = {
|
||||
reward_video: 'blue',
|
||||
signin_boost: 'cyan',
|
||||
feed: 'purple',
|
||||
signin: 'green',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 10; // 金币记录每页条数
|
||||
|
||||
interface Props {
|
||||
userId: number;
|
||||
user: WithdrawUserSnapshot | null;
|
||||
}
|
||||
|
||||
/** 提现详情抽屉:用户基本信息 + 互斥时间筛选 + 看广告/提现统计区 + 金币发放记录表。 */
|
||||
export default function UserRewardPanel({ userId, user }: Props) {
|
||||
const [mode, setMode] = useState<'all' | 'range'>('all'); // all=注册至今 / range=自定义区间
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
const [stats, setStats] = useState<UserRewardStats | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
|
||||
const [records, setRecords] = useState<UserCoinRecord[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||
|
||||
// 时间窗口参数:注册至今=不传;自定义区间(选齐两端)=date_from/date_to。
|
||||
const params =
|
||||
mode === 'range' && range?.[0] && range?.[1]
|
||||
? {
|
||||
date_from: range[0].startOf('day').toISOString(),
|
||||
date_to: range[1].endOf('day').toISOString(),
|
||||
}
|
||||
: {};
|
||||
const paramsKey = JSON.stringify(params);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setStatsLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<UserRewardStats>(`/admin/api/users/${userId}/reward-stats`, {
|
||||
params: JSON.parse(paramsKey),
|
||||
});
|
||||
setStats(data);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
}, [userId, paramsKey]);
|
||||
|
||||
const loadRecords = useCallback(
|
||||
async (p: number) => {
|
||||
setRecordsLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<CursorPage<UserCoinRecord>>(
|
||||
`/admin/api/users/${userId}/coin-records`,
|
||||
{ params: { ...JSON.parse(paramsKey), limit: PAGE_SIZE, cursor: (p - 1) * PAGE_SIZE } },
|
||||
);
|
||||
setRecords(data.items);
|
||||
setTotal(data.total ?? 0);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setRecordsLoading(false);
|
||||
}
|
||||
},
|
||||
[userId, paramsKey],
|
||||
);
|
||||
|
||||
// 用户或时间筛选变化:回到第 1 页重新拉取
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
setPage(1);
|
||||
loadRecords(1);
|
||||
}, [loadStats, loadRecords]);
|
||||
|
||||
const regDays = user?.created_at ? dayjs().diff(apiTime(user.created_at), 'day') : null;
|
||||
|
||||
const columns: ColumnsType<UserCoinRecord> = [
|
||||
{ title: '日期时间', dataIndex: 'created_at', width: 150, render: dt },
|
||||
{
|
||||
title: '赚取途径',
|
||||
dataIndex: 'source_label',
|
||||
width: 120,
|
||||
render: (label: string, r) => <Tag color={SOURCE_COLOR[r.source] ?? 'default'}>{label}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'ECPM',
|
||||
dataIndex: 'ecpm',
|
||||
width: 100,
|
||||
render: (v: string | null) => v ?? <Text type="secondary">-</Text>,
|
||||
},
|
||||
{ title: '发放金币数', dataIndex: 'coin', width: 100, render: (v: number) => <b>{v}</b> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 用户基本信息 + 互斥时间筛选(同一行) */}
|
||||
<Space
|
||||
wrap
|
||||
align="center"
|
||||
style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }}
|
||||
>
|
||||
<Space size="large" wrap>
|
||||
<span>
|
||||
手机号 <Text strong>{user?.phone || '-'}</Text>
|
||||
</span>
|
||||
<span>
|
||||
昵称 <Text strong>{user?.nickname || '-'}</Text>
|
||||
</span>
|
||||
<span>
|
||||
微信昵称 <Text strong>{user?.wechat_nickname || '-'}</Text>
|
||||
</span>
|
||||
<span>
|
||||
注册天数 <Text strong>{regDays != null ? `${regDays} 天` : '-'}</Text>
|
||||
</span>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<Radio.Group
|
||||
size="small"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="all">注册至今</Radio.Button>
|
||||
<Radio.Button value="range">自定义区间</Radio.Button>
|
||||
</Radio.Group>
|
||||
<RangePicker
|
||||
size="small"
|
||||
disabled={mode !== 'range'}
|
||||
value={range}
|
||||
onChange={(v) => setRange(v as [Dayjs, Dayjs] | null)}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{/* 统计区(受时间筛选;现金余额为当前快照) */}
|
||||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 8 }}>
|
||||
<Descriptions.Item label="累计提现">
|
||||
{stats ? yuan(stats.withdraw_success_cents) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="现金余额">
|
||||
{stats ? yuan(stats.cash_balance_cents) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提现总次数">{stats?.withdraw_total ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="传统任务提现">
|
||||
{stats ? yuan(stats.traditional_task_cash_cents) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="累计激励视频数">
|
||||
{stats?.reward_video_count ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平均激励视频ECPM">
|
||||
{stats ? `${stats.reward_video_avg_ecpm} 分/千` : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="激励视频提现">
|
||||
{stats ? yuan(stats.reward_video_cash_cents) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="累计信息流广告数">{stats?.feed_count ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="平均信息流广告ECPM">
|
||||
{stats ? `${stats.feed_avg_ecpm} 分/千` : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="信息流广告提现">
|
||||
{stats ? yuan(stats.feed_cash_cents) : '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
统计随上方时间筛选(现金余额除外,为当前快照);eCPM 单位分/千次,同金币审计。
|
||||
</Text>
|
||||
|
||||
{/* 金币记录 */}
|
||||
<h4 style={{ margin: '16px 0 8px' }}>金币记录</h4>
|
||||
<Table
|
||||
rowKey={(r) => `${r.source}-${r.created_at}-${r.coin}-${r.ecpm ?? ''}`}
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
loading={recordsLoading || statsLoading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: PAGE_SIZE,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
onChange: (p) => {
|
||||
setPage(p);
|
||||
loadRecords(p);
|
||||
},
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { Key } from 'react';
|
||||
import {
|
||||
App,
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
Tag,
|
||||
Timeline,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
@@ -36,35 +36,28 @@ import {
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { useCursorList } from '@/lib/useCursorList';
|
||||
import { formatUtcTime, utcDayjs, utcFromNow, yuan } from '@/lib/format';
|
||||
import { usePagedList } from '@/lib/usePagedList';
|
||||
import type {
|
||||
AuditLog,
|
||||
CashTxn,
|
||||
WithdrawBulkResult,
|
||||
WithdrawDetail,
|
||||
WithdrawLedgerCheck,
|
||||
WithdrawListItem,
|
||||
WithdrawOrder,
|
||||
WithdrawSummary,
|
||||
WxpayHealthCheck,
|
||||
} from '@/lib/types';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.locale('zh-cn');
|
||||
import UserRewardPanel from './UserRewardPanel';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
|
||||
const apiTime = (v: string) => {
|
||||
const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v);
|
||||
return dayjs(hasTimezone ? v : `${v}Z`);
|
||||
};
|
||||
const dt = (v: string) => apiTime(v).format('YYYY-MM-DD HH:mm');
|
||||
const ago = (v: string) => apiTime(v).fromNow();
|
||||
// 提现单 / 审计 / 用户均为 UTC 口径(server_default=func.now());现金流水为北京 wall-clock。
|
||||
const dt = (v: string) => formatUtcTime(v);
|
||||
const ago = (v: string) => utcFromNow(v);
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
reviewing: 'gold',
|
||||
@@ -109,19 +102,12 @@ const QUICK_FILTER_STATUS: Record<string, string> = {
|
||||
};
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'created_at', label: '申请时间' },
|
||||
{ value: 'updated_at', label: '更新时间' },
|
||||
{ value: 'amount_cents', label: '提现金额' },
|
||||
{ value: 'id', label: '记录 ID' },
|
||||
];
|
||||
const DATE_FIELD_OPTIONS = [
|
||||
{ value: 'created_at', label: '申请时间' },
|
||||
{ value: 'updated_at', label: '更新时间' },
|
||||
];
|
||||
const CASH_BIZ_LABEL: Record<string, string> = {
|
||||
exchange_in: '金币兑入',
|
||||
withdraw: '提现扣款',
|
||||
withdraw_refund: '提现退款',
|
||||
};
|
||||
|
||||
function statusTag(status: string) {
|
||||
return <Tag color={STATUS_COLOR[status]}>{STATUS_LABEL[status] || status}</Tag>;
|
||||
@@ -161,11 +147,12 @@ function riskTags(detail: WithdrawDetail | null) {
|
||||
const tags: string[] = [];
|
||||
if (!detail.order.user_name) tags.push('缺少提现实名');
|
||||
if (detail.user?.status && detail.user.status !== 'active') tags.push(`账号状态:${detail.user.status}`);
|
||||
if (detail.user && dayjs().diff(apiTime(detail.user.created_at), 'hour') < 24) tags.push('新注册用户');
|
||||
if (detail.user && dayjs().diff(utcDayjs(detail.user.created_at), 'hour') < 24) tags.push('新注册用户');
|
||||
if ((detail.user?.withdraw_total || 0) >= 3) tags.push('多次提现用户');
|
||||
if (detail.recent_withdraws.some((o) => o.status === 'rejected' || o.status === 'failed')) {
|
||||
tags.push('历史异常单');
|
||||
}
|
||||
const rejectedN = detail.recent_withdraws.filter((o) => o.status === 'rejected').length;
|
||||
const failedN = detail.recent_withdraws.filter((o) => o.status === 'failed').length;
|
||||
if (rejectedN) tags.push(`提现拒绝${rejectedN}笔`);
|
||||
if (failedN) tags.push(`提现失败${failedN}笔`);
|
||||
return tags;
|
||||
}
|
||||
|
||||
@@ -177,10 +164,12 @@ function bulkResultText(action: string, result: WithdrawBulkResult) {
|
||||
}
|
||||
|
||||
export default function WithdrawsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [activeStatus, setActiveStatus] = useState('reviewing');
|
||||
const [summary, setSummary] = useState<WithdrawSummary | null>(null);
|
||||
const [health, setHealth] = useState<WxpayHealthCheck | null>(null);
|
||||
const [ledger, setLedger] = useState<WithdrawLedgerCheck | null>(null);
|
||||
const [ledgerLoading, setLedgerLoading] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<WithdrawDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
@@ -188,14 +177,11 @@ export default function WithdrawsPage() {
|
||||
const [bulkRejecting, setBulkRejecting] = useState<WithdrawOrder[]>([]);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
const [dateField, setDateField] = useState<'created_at' | 'updated_at'>('created_at');
|
||||
const [sortBy, setSortBy] = useState<'created_at' | 'updated_at' | 'amount_cents' | 'id'>(
|
||||
'created_at',
|
||||
);
|
||||
const [sortBy, setSortBy] = useState<'created_at' | 'amount_cents'>('created_at');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [quickFilter, setQuickFilter] = useState<string | undefined>(undefined);
|
||||
|
||||
@@ -209,11 +195,8 @@ export default function WithdrawsPage() {
|
||||
filters.sort_by = sortBy;
|
||||
filters.sort_order = sortOrder;
|
||||
const filterKey = JSON.stringify(filters);
|
||||
const { items, nextCursor, loading, loadMore, reload } = useCursorList<WithdrawOrder>(
|
||||
'/admin/api/withdraws',
|
||||
filters,
|
||||
pageSize,
|
||||
);
|
||||
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
|
||||
usePagedList<WithdrawListItem>('/admin/api/withdraws', filters, 20);
|
||||
const canManage = canDo(['finance']);
|
||||
const selectedOrders = items.filter((item) => selectedRowKeys.includes(item.id));
|
||||
const selectedReviewing = selectedOrders.filter((item) => item.status === 'reviewing');
|
||||
@@ -255,19 +238,28 @@ export default function WithdrawsPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadChecks = useCallback(async () => {
|
||||
const loadHealth = useCallback(async () => {
|
||||
try {
|
||||
const [{ data: healthData }, { data: ledgerData }] = await Promise.all([
|
||||
api.get<WxpayHealthCheck>('/admin/api/withdraws/health-check'),
|
||||
api.get<WithdrawLedgerCheck>('/admin/api/withdraws/ledger-check'),
|
||||
]);
|
||||
setHealth(healthData);
|
||||
setLedger(ledgerData);
|
||||
const { data } = await api.get<WxpayHealthCheck>('/admin/api/withdraws/health-check');
|
||||
setHealth(data);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 账本校验是全表对账、相对重,故只在进页时拉一次 + 提供手动按钮,不随每次写操作重算。
|
||||
const loadLedger = useCallback(async () => {
|
||||
setLedgerLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<WithdrawLedgerCheck>('/admin/api/withdraws/ledger-check');
|
||||
setLedger(data);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setLedgerLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadDetail = useCallback(async (outBillNo: string) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
@@ -282,17 +274,19 @@ export default function WithdrawsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void loadSummary();
|
||||
void loadChecks();
|
||||
}, [loadChecks, loadSummary]);
|
||||
void loadHealth();
|
||||
void loadLedger();
|
||||
}, [loadHealth, loadLedger, loadSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRowKeys([]);
|
||||
}, [filterKey, pageSize]);
|
||||
}, [filterKey, page, pageSize]);
|
||||
|
||||
const refreshAfterChange = async (outBillNo?: string) => {
|
||||
setSelectedRowKeys([]);
|
||||
reload();
|
||||
await Promise.all([loadSummary(), loadChecks()]);
|
||||
// 账本校验偏重,写操作后不重算(只刷 summary + health);需要时点账本 Alert 上的「重新校验」。
|
||||
await Promise.all([loadSummary(), loadHealth()]);
|
||||
if (outBillNo && drawerOpen && detail?.order.out_bill_no === outBillNo) {
|
||||
await loadDetail(outBillNo);
|
||||
}
|
||||
@@ -305,7 +299,7 @@ export default function WithdrawsPage() {
|
||||
};
|
||||
|
||||
const approve = (o: WithdrawOrder) => {
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `确认通过并打款 ${yuan(o.amount_cents)}?`,
|
||||
content: (
|
||||
<div>
|
||||
@@ -368,7 +362,7 @@ export default function WithdrawsPage() {
|
||||
}
|
||||
const amount = selectedReviewing.reduce((sum, item) => sum + item.amount_cents, 0);
|
||||
let confirmText = '';
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `确认批量通过 ${selectedReviewing.length} 笔提现?`,
|
||||
content: (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
@@ -406,7 +400,7 @@ export default function WithdrawsPage() {
|
||||
message.warning('请选择打款中的提现单');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `确认批量刷新 ${selectedPending.length} 笔打款状态?`,
|
||||
content: '会逐笔向微信查单,并按最新状态归一化。',
|
||||
okText: '批量刷新查单',
|
||||
@@ -428,7 +422,7 @@ export default function WithdrawsPage() {
|
||||
};
|
||||
|
||||
const retry = (o: WithdrawOrder) => {
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `重新查询 ${o.out_bill_no} 的微信状态?`,
|
||||
content: '会按微信最新状态归一化为成功、失败退款或撤销未确认。',
|
||||
okText: '刷新查单',
|
||||
@@ -450,7 +444,7 @@ export default function WithdrawsPage() {
|
||||
};
|
||||
|
||||
const reconcile = () => {
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: '批量对账?',
|
||||
content: '扫描超过 15 分钟仍处于打款中的提现单,逐单向微信查实并归一化。',
|
||||
okText: '开始对账',
|
||||
@@ -468,37 +462,37 @@ export default function WithdrawsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnsType<WithdrawOrder> = [
|
||||
const columns: ColumnsType<WithdrawListItem> = [
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'user_id',
|
||||
width: 110,
|
||||
render: (v: number) => <Text strong>#{v}</Text>,
|
||||
},
|
||||
{
|
||||
title: '提现实名',
|
||||
dataIndex: 'user_name',
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
width: 130,
|
||||
render: (v: string | null) => v || <Text type="secondary">未填写</Text>,
|
||||
render: (v: string | null) => v || <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
title: '用户昵称',
|
||||
dataIndex: 'nickname',
|
||||
width: 130,
|
||||
render: (v: string | null) => v || <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '提现金额',
|
||||
dataIndex: 'amount_cents',
|
||||
width: 120,
|
||||
render: (v: number) => <Text strong>{yuan(v)}</Text>,
|
||||
},
|
||||
{
|
||||
title: '累计提现',
|
||||
dataIndex: 'cumulative_success_cents',
|
||||
width: 120,
|
||||
render: (v: number) => yuan(v ?? 0),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: statusTag,
|
||||
},
|
||||
{
|
||||
title: '微信状态',
|
||||
dataIndex: 'wechat_state',
|
||||
width: 150,
|
||||
render: (v: string | null) => v || <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '提交时间',
|
||||
dataIndex: 'created_at',
|
||||
@@ -511,7 +505,7 @@ export default function WithdrawsPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '提现单号',
|
||||
title: '提交单号',
|
||||
dataIndex: 'out_bill_no',
|
||||
ellipsis: true,
|
||||
},
|
||||
@@ -526,7 +520,7 @@ export default function WithdrawsPage() {
|
||||
key: 'op',
|
||||
fixed: 'right',
|
||||
width: 210,
|
||||
render: (_: unknown, o: WithdrawOrder) => {
|
||||
render: (_: unknown, o: WithdrawListItem) => {
|
||||
if (!canManage) return <Text type="secondary">-</Text>;
|
||||
if (o.status === 'reviewing') {
|
||||
return (
|
||||
@@ -575,36 +569,6 @@ export default function WithdrawsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const cashColumns: ColumnsType<CashTxn> = [
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'biz_type',
|
||||
width: 130,
|
||||
render: (v: string) => CASH_BIZ_LABEL[v] || v,
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount_cents',
|
||||
width: 100,
|
||||
render: (v: number) => (
|
||||
<Text style={{ color: v < 0 ? '#cf1322' : '#389e0d' }}>
|
||||
{v > 0 ? '+' : ''}
|
||||
{yuan(v)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ title: '余额', dataIndex: 'balance_after_cents', width: 100, render: yuan },
|
||||
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 150, render: dt },
|
||||
];
|
||||
|
||||
const recentWithdrawColumns: ColumnsType<WithdrawOrder> = [
|
||||
{ title: '单号', dataIndex: 'out_bill_no', ellipsis: true },
|
||||
{ title: '金额', dataIndex: 'amount_cents', width: 90, render: yuan },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: statusTag },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 150, render: dt },
|
||||
];
|
||||
|
||||
const auditTimeline = detail
|
||||
? [
|
||||
{
|
||||
@@ -686,40 +650,30 @@ export default function WithdrawsPage() {
|
||||
? `账户余额合计 ${yuan(ledger.cash_balance_total_cents)},现金流水净额 ${yuan(ledger.cash_transaction_total_cents)}`
|
||||
: ledgerIssues.join(';')
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={ledgerLoading}
|
||||
onClick={() => void loadLedger()}
|
||||
>
|
||||
重新校验
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} xl={5}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card size="small">
|
||||
<Statistic title="待审核" value={summary?.reviewing_count || 0} suffix="笔" />
|
||||
<Text type="secondary">金额 {yuan(summary?.reviewing_amount_cents || 0)}</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={5}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card size="small">
|
||||
<Statistic title="今日拒绝" value={summary?.today_rejected_count || 0} suffix="笔" />
|
||||
<Text type="secondary">拒绝原因会同步给用户</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={5}>
|
||||
<Card size="small">
|
||||
<Statistic title="打款中" value={summary?.pending_count || 0} suffix="笔" />
|
||||
<Text type="secondary">可刷新查单</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="失败待处理" value={summary?.failed_count || 0} suffix="笔" />
|
||||
<Text type="secondary">需关注退款结果</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={5}>
|
||||
<Card size="small">
|
||||
<Statistic title="今日到账" value={summary?.today_success_count || 0} suffix="笔" />
|
||||
<Text type="secondary">金额 {yuan(summary?.today_success_amount_cents || 0)}</Text>
|
||||
<Statistic title="待审核金额" value={yuan(summary?.reviewing_amount_cents || 0)} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -760,9 +714,9 @@ export default function WithdrawsPage() {
|
||||
allowClear
|
||||
enterButton="搜索"
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="用户ID / 手机号 / 单号 / 原因"
|
||||
placeholder="用户手机号"
|
||||
value={searchDraft}
|
||||
style={{ width: 320 }}
|
||||
style={{ width: 240 }}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSearchDraft(value);
|
||||
@@ -809,9 +763,7 @@ export default function WithdrawsPage() {
|
||||
style={{ width: 130 }}
|
||||
suffixIcon={<SortAscendingOutlined />}
|
||||
options={SORT_OPTIONS}
|
||||
onChange={(value: 'created_at' | 'updated_at' | 'amount_cents' | 'id') =>
|
||||
setSortBy(value)
|
||||
}
|
||||
onChange={(value: 'created_at' | 'amount_cents') => setSortBy(value)}
|
||||
/>
|
||||
<Select
|
||||
value={sortOrder}
|
||||
@@ -872,31 +824,6 @@ export default function WithdrawsPage() {
|
||||
</Space>
|
||||
</Space>
|
||||
)}
|
||||
<Space
|
||||
wrap
|
||||
style={{
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text type="secondary">
|
||||
当前已加载 {items.length} 条{nextCursor ? ',还有更多' : ',已到最后一页'}
|
||||
</Text>
|
||||
<Space>
|
||||
<Text type="secondary">每次加载</Text>
|
||||
<Select
|
||||
value={pageSize}
|
||||
style={{ width: 96 }}
|
||||
onChange={(value: number) => setPageSize(value)}
|
||||
options={[
|
||||
{ value: 20, label: '20 条' },
|
||||
{ value: 50, label: '50 条' },
|
||||
{ value: 100, label: '100 条' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
rowSelection={
|
||||
@@ -913,23 +840,26 @@ export default function WithdrawsPage() {
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: onPageChange,
|
||||
}}
|
||||
scroll={{ x: 1350 }}
|
||||
onRow={(record) => ({
|
||||
onClick: () => openDetail(record),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
/>
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||
{nextCursor ? `继续加载 ${pageSize} 条` : '没有更多了'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
title="提现详情"
|
||||
width={720}
|
||||
width="50%"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
@@ -968,29 +898,10 @@ export default function WithdrawsPage() {
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Descriptions bordered size="small" column={2} title="用户与钱包">
|
||||
<Descriptions.Item label="用户 ID">#{detail.order.user_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="账号状态">
|
||||
{detail.user ? <Tag>{detail.user.status}</Tag> : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">{detail.user?.phone || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="昵称">{detail.user?.nickname || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="微信昵称">
|
||||
{detail.user?.wechat_nickname || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="现金余额">
|
||||
{yuan(detail.user?.cash_balance_cents || 0)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提现总次数">
|
||||
{detail.user?.withdraw_total ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="成功提现">
|
||||
{yuan(detail.user?.withdraw_success_cents || 0)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<UserRewardPanel userId={detail.order.user_id} user={detail.user} />
|
||||
|
||||
<div>
|
||||
<h3>风险提示 {detail.risk_score ? <Tag color="red">{detail.risk_score}分</Tag> : null}</h3>
|
||||
<h3>风险提示</h3>
|
||||
{risks.length ? (
|
||||
<Space wrap>
|
||||
{risks.map((risk) => (
|
||||
@@ -1008,28 +919,6 @@ export default function WithdrawsPage() {
|
||||
<h3>处理时间线</h3>
|
||||
<Timeline items={auditTimeline} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>最近提现</h3>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={recentWithdrawColumns}
|
||||
dataSource={detail.recent_withdraws}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>最近现金流水</h3>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={cashColumns}
|
||||
dataSource={detail.recent_cash_transactions}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Card, Form, Input, message } from 'antd';
|
||||
import { App, Button, Card, Form, Input } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { setAuth } from '@/lib/auth';
|
||||
import type { LoginResponse } from '@/lib/types';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { message } = App.useApp();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
|
||||
+15
-3
@@ -5,15 +5,27 @@
|
||||
// 必须在引入 antd 之前 import。
|
||||
import '@ant-design/v5-patch-for-react-19';
|
||||
import { AntdRegistry } from '@ant-design/nextjs-registry';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import { App, ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AntdRegistry>
|
||||
{/* wave 禁用:消除 antd 5 波纹在 Next15 dev 下的 React 版本兼容误报(纯噪音,后台不需要波纹) */}
|
||||
<ConfigProvider locale={zhCN} wave={{ disabled: true }}>
|
||||
{children}
|
||||
{/* 全局主题:统一圆角到 8、页面底色用偏冷的浅灰,让白色卡片更有层次(全站生效) */}
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
wave={{ disabled: true }}
|
||||
theme={{
|
||||
token: {
|
||||
borderRadius: 8,
|
||||
colorBgLayout: '#f0f2f5',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* antd <App> 容器:让 message/notification/Modal 能通过 App.useApp() 读到 ConfigProvider
|
||||
的动态主题/locale,消除 "Static function can not consume context" 告警(全站生效) */}
|
||||
<App>{children}</App>
|
||||
</ConfigProvider>
|
||||
</AntdRegistry>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
// 调金币 / 调现金弹窗(受控)。列表页与用户详情页共用,避免两处各写一套。
|
||||
// 每个弹窗自带「增减 / 设为指定值」两种模式;打开时拉一次 360 概览展示当前余额。
|
||||
import { useEffect, useState } from 'react';
|
||||
import { App, Form, Input, InputNumber, Modal, Segmented } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { yuan } from '@/lib/format';
|
||||
import type { UserOverview } from '@/lib/types';
|
||||
|
||||
export type AdjustTargetUser = { id: number; phone: string };
|
||||
|
||||
interface ModalProps {
|
||||
user: AdjustTargetUser | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onDone?: () => void; // 调整成功后回调(刷新余额/列表)
|
||||
}
|
||||
|
||||
const balanceLine = (balances: { coin: number; cashCents: number } | null) => (
|
||||
<p style={{ color: '#888', marginBottom: 12 }}>
|
||||
当前余额:金币 {balances ? balances.coin : '…'} | 现金 {balances ? yuan(balances.cashCents) : '…'}
|
||||
</p>
|
||||
);
|
||||
|
||||
// 弹窗打开时拉一次该用户余额;用户切换/关闭时丢弃过期响应(alive 标记防竞态)。
|
||||
function useBalances(userId: number | undefined, open: boolean) {
|
||||
const [balances, setBalances] = useState<{ coin: number; cashCents: number } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open || userId == null) {
|
||||
setBalances(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setBalances(null);
|
||||
api
|
||||
.get<UserOverview>(`/admin/api/users/${userId}`)
|
||||
.then((r) => {
|
||||
if (alive) setBalances({ coin: r.data.coin_balance, cashCents: r.data.cash_balance_cents });
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [userId, open]);
|
||||
return balances;
|
||||
}
|
||||
|
||||
export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
const [mode, setMode] = useState<'delta' | 'set'>('delta');
|
||||
const balances = useBalances(user?.id, open);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMode('delta');
|
||||
form.resetFields();
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!user) return;
|
||||
const v = await form.validateFields();
|
||||
try {
|
||||
await api.post(`/admin/api/users/${user.id}/coins`, { mode, ...v });
|
||||
message.success(mode === 'set' ? '已设置金币' : '已调整金币');
|
||||
onClose();
|
||||
onDone?.();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`调整金币 - ${user?.phone ?? ''}`}
|
||||
open={open}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
>
|
||||
{balanceLine(balances)}
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="操作方式">
|
||||
<Segmented
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as 'delta' | 'set')}
|
||||
options={[
|
||||
{ label: '增减', value: 'delta' },
|
||||
{ label: '设为指定值', value: 'set' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="amount"
|
||||
label={mode === 'set' ? '目标金币值(直接设为该值)' : '金币变动(正=增加,负=扣减)'}
|
||||
rules={[
|
||||
{ required: true },
|
||||
...(mode === 'set' ? [{ type: 'number' as const, min: 0, message: '目标值不能为负' }] : []),
|
||||
]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} min={mode === 'set' ? 0 : undefined} />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
const [mode, setMode] = useState<'delta' | 'set'>('delta');
|
||||
const balances = useBalances(user?.id, open);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMode('delta');
|
||||
form.resetFields();
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
// 输入元,×100 转分后调后端(主要给无现金用户发钱直接测试提现)
|
||||
const submit = async () => {
|
||||
if (!user) return;
|
||||
const v = await form.validateFields();
|
||||
const amountCents = Math.round(Number(v.amount_yuan) * 100);
|
||||
if (mode === 'delta' && amountCents === 0) {
|
||||
message.warning('金额不能为 0(且不小于 1 分)');
|
||||
return;
|
||||
}
|
||||
if (mode === 'set' && amountCents < 0) {
|
||||
message.warning('目标金额不能为负');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post(`/admin/api/users/${user.id}/cash`, {
|
||||
mode,
|
||||
amount_cents: amountCents,
|
||||
reason: v.reason,
|
||||
});
|
||||
message.success(
|
||||
mode === 'set'
|
||||
? `已设为 ¥${(amountCents / 100).toFixed(2)} 现金`
|
||||
: `已${amountCents > 0 ? '发放' : '扣减'} ¥${(Math.abs(amountCents) / 100).toFixed(2)} 现金`,
|
||||
);
|
||||
onClose();
|
||||
onDone?.();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`调现金 - ${user?.phone ?? ''}`}
|
||||
open={open}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
>
|
||||
{balanceLine(balances)}
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="操作方式">
|
||||
<Segmented
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as 'delta' | 'set')}
|
||||
options={[
|
||||
{ label: '增减', value: 'delta' },
|
||||
{ label: '设为指定值', value: 'set' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="amount_yuan"
|
||||
label={
|
||||
mode === 'set'
|
||||
? '目标现金(元,直接设为该值,须≥0)'
|
||||
: '现金变动(元,正=发放,负=扣减;不可扣成负)'
|
||||
}
|
||||
rules={[
|
||||
{ required: true },
|
||||
...(mode === 'set' ? [{ type: 'number' as const, min: 0, message: '目标值不能为负' }] : []),
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
step={0.01}
|
||||
precision={2}
|
||||
suffix="元"
|
||||
min={mode === 'set' ? 0 : undefined}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// 全站统一的金额 / 时间格式化。
|
||||
//
|
||||
// 后端有两种时间口径,序列化后从字符串无法区分,必须按字段语义选对函数:
|
||||
// 1. UTC 口径:`server_default=func.now()` 的字段(用户/提现单/审计日志/广告发奖记录)。
|
||||
// 入库是 UTC,SQLite 下序列化不带时区、Postgres 下带 +00:00。统一当 UTC 解析后转北京显示。
|
||||
// 2. 北京 wall-clock 口径:钱包流水(coin_transaction / cash_transaction)。入库即
|
||||
// `datetime.now(CN_TZ).replace(tzinfo=None)`,字面就是北京时间且无时区,**不能再做时区换算**。
|
||||
// 历史上各页面混用 `new Date().toLocaleString`(按本地解析)与 `apiTime`(补 Z 当 UTC),
|
||||
// 导致提现详情里现金流水时间快 8 小时。此模块收口,按口径区分。
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const TZ = 'Asia/Shanghai';
|
||||
|
||||
/** 金额:分 → ¥元(两位小数)。 */
|
||||
export const yuan = (cents: number) => `¥${(cents / 100).toFixed(2)}`;
|
||||
|
||||
const hasTz = (v: string) => /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v);
|
||||
|
||||
/** 把 UTC 口径字符串解析为带时区的 dayjs(无时区后缀的补 Z 当 UTC)。 */
|
||||
export const utcDayjs = (v: string) => dayjs(hasTz(v) ? v : `${v}Z`);
|
||||
|
||||
/** UTC 口径时间 → 北京时间显示(用户/提现单/审计/广告发奖记录)。 */
|
||||
export function formatUtcTime(v?: string | null, fmt = 'YYYY-MM-DD HH:mm'): string {
|
||||
if (!v) return '-';
|
||||
return utcDayjs(v).tz(TZ).format(fmt);
|
||||
}
|
||||
|
||||
/** UTC 口径相对时间(如「3 分钟前」)。 */
|
||||
export function utcFromNow(v?: string | null): string {
|
||||
if (!v) return '-';
|
||||
return utcDayjs(v).fromNow();
|
||||
}
|
||||
|
||||
/** 北京 wall-clock 口径时间 → 原样格式化,不做时区换算(钱包金币/现金流水)。 */
|
||||
export function formatWallTime(v?: string | null, fmt = 'YYYY-MM-DD HH:mm'): string {
|
||||
if (!v) return '-';
|
||||
return dayjs(v).format(fmt);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// 媒体(上报截图 / 反馈截图 / 反馈二维码等)由 App 后端的 /media 静态服务托管,与 admin 不同域:
|
||||
// - dev :NEXT_PUBLIC_MEDIA_BASE = http://localhost:8770(本地 App 后端)。
|
||||
// - 生产:NEXT_PUBLIC_MEDIA_BASE = https://app-api.shaguabijia.com(线上 App 后端,/media 真正的家)。
|
||||
// 留空则退化为同域相对路径——而 admin 域(admin-web.shaguabijia.com)并不 serve /media,会 404,
|
||||
// 这正是线上后台图片打不开的根因。NEXT_PUBLIC_* 是编译期常量,改完需重新 build 才生效。
|
||||
const MEDIA_BASE = (process.env.NEXT_PUBLIC_MEDIA_BASE || '').replace(/\/+$/, ''); // 去末尾斜杠,防拼出 //media
|
||||
|
||||
/** 把后端返回的相对媒体路径(/media/...)拼成可加载的 URL;已是 http(s) 绝对地址的原样返回。 */
|
||||
export function mediaUrl(p: string): string {
|
||||
if (!p) return p;
|
||||
return p.startsWith('http') ? p : MEDIA_BASE + p;
|
||||
}
|
||||
+317
-1
@@ -19,6 +19,7 @@ export interface LoginResponse {
|
||||
export interface CursorPage<T> {
|
||||
items: T[];
|
||||
next_cursor: number | null;
|
||||
total?: number | null; // offset 分页时为符合筛选条件的总条数(页码分页用);加载更多接口可能没有
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
@@ -27,11 +28,22 @@ export interface UserListItem {
|
||||
nickname: string | null;
|
||||
register_channel: string;
|
||||
status: string;
|
||||
// 调试链接权限:开了的用户在 App 比价结果页/记录页可复制 price.shaguabijia.com 的 trace 调试链接
|
||||
debug_trace_enabled: boolean;
|
||||
// 运营「一键开启新手引导」:true = 该用户下次打开 App 会被强制重走新手引导,走完自动取消
|
||||
force_onboarding: boolean;
|
||||
wechat_openid: string | null;
|
||||
created_at: string;
|
||||
last_login_at: string;
|
||||
}
|
||||
|
||||
// 设备维度新手引导:一台设备(ANDROID_ID)聚合,走过引导的账号数 + 最近完成时间。
|
||||
export interface DeviceOnboardingItem {
|
||||
device_id: string;
|
||||
account_count: number;
|
||||
last_completed_at: string;
|
||||
}
|
||||
|
||||
export interface UserOverview {
|
||||
user: UserListItem;
|
||||
coin_balance: number;
|
||||
@@ -80,6 +92,14 @@ export interface WithdrawOrder {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 提现列表项:WithdrawOrder + 列表接口联表带出的用户字段。
|
||||
// 详情抽屉的 recent_withdraws 仍是裸 WithdrawOrder(无这些字段)。
|
||||
export interface WithdrawListItem extends WithdrawOrder {
|
||||
phone: string | null;
|
||||
nickname: string | null;
|
||||
cumulative_success_cents: number; // 累计成功提现(分)
|
||||
}
|
||||
|
||||
export interface WithdrawSummary {
|
||||
reviewing_count: number;
|
||||
reviewing_amount_cents: number;
|
||||
@@ -114,6 +134,30 @@ export interface WithdrawDetail {
|
||||
audit_logs: AuditLog[];
|
||||
}
|
||||
|
||||
// 提现详情抽屉「用户统计区」:按时间窗口算的提现 + 看广告行为统计。
|
||||
// *_cash_cents = 该来源累计金币折算成可提现现金(分);现金余额是当前快照不随窗口。
|
||||
export interface UserRewardStats {
|
||||
withdraw_success_cents: number; // 累计提现
|
||||
cash_balance_cents: number; // 现金余额(快照)
|
||||
withdraw_total: number; // 提现总次数(全部状态)
|
||||
traditional_task_cash_cents: number; // 传统任务提现
|
||||
reward_video_count: number; // 累计激励视频数
|
||||
reward_video_avg_ecpm: number; // 平均激励视频 eCPM(分/千次)
|
||||
reward_video_cash_cents: number; // 激励视频提现
|
||||
feed_count: number; // 累计信息流广告数(份)
|
||||
feed_avg_ecpm: number; // 平均信息流广告 eCPM(分/千次)
|
||||
feed_cash_cents: number; // 信息流广告提现
|
||||
}
|
||||
|
||||
// 金币发放记录(提现详情底部表)。ecpm 为原始分/千次,非广告来源为 null。
|
||||
export interface UserCoinRecord {
|
||||
source: string; // reward_video / signin_boost / feed / signin
|
||||
source_label: string; // 激励视频 / 签到膨胀 / 信息流广告 / 签到
|
||||
created_at: string;
|
||||
ecpm: string | null;
|
||||
coin: number;
|
||||
}
|
||||
|
||||
export interface WithdrawBulkItemResult {
|
||||
out_bill_no: string;
|
||||
ok: boolean;
|
||||
@@ -162,10 +206,27 @@ export interface Feedback {
|
||||
content: string;
|
||||
contact: string;
|
||||
images: string[] | null;
|
||||
status: string; // new / handled
|
||||
// pending(审核中) / adopted(已采纳) / rejected(未采纳);历史数据可能为 new
|
||||
status: string;
|
||||
reject_reason: string | null; // 未采纳原因(用户端可见)
|
||||
reward_coins: number | null; // 采纳后发放金币
|
||||
review_note: string | null; // 审核批注(采纳要点 / 内部备注)
|
||||
reviewed_by_admin_id: number | null;
|
||||
reviewed_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 反馈页「加群二维码」卡配置(运营后台改 → App 意见反馈页同步)。
|
||||
// image_url 为后端 /media 相对路径(留空=App 走本地兜底图);其余为卡片三行文案与显隐开关。
|
||||
export interface FeedbackQrConfig {
|
||||
enabled: boolean;
|
||||
image_url: string | null;
|
||||
title: string; // 第一行(操作提示),如「长按图片保存二维码」
|
||||
group_name: string; // 第二行加粗群名,App 渲染为 直通「{group_name}」
|
||||
subtitle: string; // 第三行(标语)
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: number;
|
||||
admin_id: number;
|
||||
@@ -178,6 +239,149 @@ export interface AuditLog {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 广告收益报表聚合行下钻的单条展示明细
|
||||
// ===== 比价记录(admin debug 页)=====
|
||||
export interface ComparisonRecordListItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
phone: string | null;
|
||||
nickname: string | null;
|
||||
business_type: string;
|
||||
trace_id: string;
|
||||
trace_url: string | null;
|
||||
status: string;
|
||||
information: string | null;
|
||||
store_name: string | null;
|
||||
source_platform_name: string | null;
|
||||
best_platform_name: string | null;
|
||||
source_price_cents: number | null;
|
||||
best_price_cents: number | null;
|
||||
saved_amount_cents: number | null;
|
||||
total_ms: number | null;
|
||||
step_count: number | null;
|
||||
llm_call_count: number | null;
|
||||
retry_count: number | null;
|
||||
input_tokens: number | null;
|
||||
output_tokens: number | null;
|
||||
device_model: string | null;
|
||||
rom_vendor: string | null;
|
||||
rom_name: string | null;
|
||||
android_version: string | null;
|
||||
app_version: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 单次 LLM 调用明细(pricebot chat() 收口落盘,server 按 trace 拉来)
|
||||
export interface LlmCall {
|
||||
ts?: number;
|
||||
scene: string;
|
||||
model: string;
|
||||
input_messages: { role: string; content: string }[];
|
||||
output: string | null;
|
||||
usage: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } | null;
|
||||
latency_ms: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ComparisonRecordDetail extends ComparisonRecordListItem {
|
||||
source_platform_id: string | null;
|
||||
source_package: string | null;
|
||||
best_platform_id: string | null;
|
||||
best_deeplink: string | null;
|
||||
is_source_best: boolean | null;
|
||||
total_dish_count: number | null;
|
||||
skipped_dish_count: number | null;
|
||||
device_id: string | null;
|
||||
items: { name: string; qty?: number; specs?: string[] }[];
|
||||
comparison_results: Record<string, unknown>[];
|
||||
skipped_dish_names: string[];
|
||||
device_manufacturer: string | null;
|
||||
rom_version: number | null;
|
||||
android_sdk: number | null;
|
||||
app_version_code: number | null;
|
||||
source_app_version: string | null;
|
||||
longitude: number | null;
|
||||
latitude: number | null;
|
||||
llm_calls: LlmCall[] | null;
|
||||
raw_payload: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AdRevenueImpression {
|
||||
id: number;
|
||||
created_at: string;
|
||||
ecpm: string; // 分/千次展示
|
||||
revenue_yuan: number; // 本次展示预估收益(元)
|
||||
adn: string | null;
|
||||
slot_id: string | null; // 底层 mediation rit
|
||||
}
|
||||
|
||||
// 广告收益报表聚合行下钻的单条发奖复算明细
|
||||
export interface AdRevenueRecord {
|
||||
record_id: number;
|
||||
created_at: string;
|
||||
status: string; // granted / capped / ecpm_missing / too_short / closed_early
|
||||
ecpm: string | null;
|
||||
ecpm_factor: number | null; // 因子1
|
||||
units: number; // 份数
|
||||
lt_index_start: number | null;
|
||||
lt_index_end: number | null;
|
||||
lt_factor_start: number | null; // 因子2
|
||||
lt_factor_end: number | null;
|
||||
expected_coin: number;
|
||||
actual_coin: number;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受 limit 影响)
|
||||
export interface AdRevenueDaily {
|
||||
date: string; // 北京时间 YYYY-MM-DD
|
||||
impressions: number;
|
||||
revenue_yuan: number;
|
||||
expected_coin: number;
|
||||
actual_coin: number;
|
||||
}
|
||||
|
||||
// 广告收益报表:一次广告事件(逐条一行)。激励视频展示+发奖按 ad_session_id 合并;信息流展示/发奖各自成行。
|
||||
export interface AdRevenueRow {
|
||||
event_key: string; // 事件稳定唯一键(前端 rowKey)
|
||||
report_date: string; // 该事件所属日期 北京时间 YYYY-MM-DD
|
||||
user_id: number;
|
||||
user_phone: string | null; // 用户手机号(admin 展示;查不到为空)
|
||||
ad_type: string; // reward_video / feed / draw
|
||||
app_env: string | null; // prod(傻瓜比价) / test(测试);旧数据为空
|
||||
our_code_id: string | null; // 我们配置的代码位 104xxx;旧数据为空
|
||||
hour: number | null; // 北京时间小时 0–23(按小时粒度时有值;按天为 null)
|
||||
created_at: string; // 事件时间(有展示=展示时间,纯发奖=发奖时间)
|
||||
// ── 展示侧 ──
|
||||
has_impression: boolean; // 是否有广告展示(信息流逐条展示=true,纯发奖行=false)
|
||||
impressions: number; // 本行展示条数 1/0(供日汇总、趋势图复用)
|
||||
ecpm: string | null; // 分/千次;展示行取展示值,纯发奖行取发奖采用值
|
||||
revenue_yuan: number; // 本次展示预估收益(元);纯发奖行=0
|
||||
adn: string | null; // 实际填充 ADN;纯发奖行为空
|
||||
slot_id: string | null; // 底层 mediation rit;纯发奖行为空
|
||||
// ── 发奖侧 ──
|
||||
has_reward: boolean; // 是否有发奖(激励视频合并行/信息流整场发奖行=true;纯展示=false)
|
||||
status: string | null; // 发奖状态 granted/closed_early/…;纯展示为空
|
||||
expected_coin: number; // 应发金币;纯展示=0
|
||||
actual_coin: number; // 实发金币;纯展示=0
|
||||
matched: boolean; // 本条应发==实发;纯展示恒 true(不计对账)
|
||||
reward_detail: AdRevenueRecord | null; // 发奖复算明细(点行展开下钻);纯展示为空
|
||||
}
|
||||
|
||||
export interface AdRevenueReport {
|
||||
date_from: string; // 起始日 北京时间 YYYY-MM-DD
|
||||
date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同)
|
||||
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
|
||||
total: number; // 广告事件总数(全量,不受 limit 影响)
|
||||
truncated: boolean;
|
||||
total_impressions: number;
|
||||
total_revenue_yuan: number;
|
||||
total_expected_coin: number;
|
||||
total_actual_coin: number;
|
||||
mismatch_count: number; // 应发≠实发的发奖条数
|
||||
items: AdRevenueRow[];
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
users: {
|
||||
total: number;
|
||||
@@ -198,3 +402,115 @@ 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;
|
||||
}
|
||||
|
||||
// ===== CPS 分发与对账 =====
|
||||
export interface CpsGroup {
|
||||
id: number;
|
||||
sid: string | null; // 仅美团群有(渠道追踪位)
|
||||
name: string;
|
||||
platforms: string[]; // meituan/taobao/jd 多选
|
||||
member_count: number | null;
|
||||
status: string; // active / archived
|
||||
remark: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CpsActivity {
|
||||
id: number;
|
||||
platform: string; // meituan / taobao / jd
|
||||
name: string;
|
||||
act_id: string | null; // 美团活动物料 ID
|
||||
product_view_sign: string | null;
|
||||
payload: string | null; // 淘宝整段淘口令 / 京东链接
|
||||
image_url: string | null; // 淘宝落地页主视觉图(绝对 URL)
|
||||
status: string;
|
||||
remark: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CpsOrder {
|
||||
id: number;
|
||||
order_id: string;
|
||||
sid: string | null;
|
||||
act_id: string | null;
|
||||
pay_price_cents: number | null;
|
||||
commission_cents: number | null;
|
||||
commission_rate: string | null; // "300"=3% "10"=0.1%
|
||||
mt_status: string | null; // 2付款 3完成 4取消 5风控 6结算
|
||||
invalid_reason: string | null;
|
||||
product_name: string | null;
|
||||
pay_time: string | null;
|
||||
}
|
||||
|
||||
export interface CpsReferralLinkItem {
|
||||
activity_id: number;
|
||||
activity_name: string;
|
||||
platform: string;
|
||||
redirect_url: string; // 我们的落地页 /c/{code}(发群用)
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface CpsReferralLinks {
|
||||
group_name: string;
|
||||
results: CpsReferralLinkItem[];
|
||||
}
|
||||
|
||||
export interface CpsReconcileResult {
|
||||
fetched: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface CpsGroupStat {
|
||||
group_id: number | null;
|
||||
sid: string | null;
|
||||
name: string;
|
||||
platforms: string[];
|
||||
member_count: number | null;
|
||||
click_pv: number;
|
||||
click_uv: number;
|
||||
copy_pv: number; // 淘宝"复制口令"次数
|
||||
copy_uv: number;
|
||||
// 对账类:淘宝/京东无法对账 → null(前端显示 "-")
|
||||
order_count: number | null;
|
||||
settled_count: number | null;
|
||||
canceled_count: number | null;
|
||||
gmv_cents: number | null;
|
||||
est_commission_cents: number | null;
|
||||
settled_commission_cents: number | null;
|
||||
}
|
||||
|
||||
export interface CpsStats {
|
||||
groups: CpsGroupStat[];
|
||||
total_order_count: number;
|
||||
total_est_commission_cents: number;
|
||||
total_settled_commission_cents: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from './api';
|
||||
import type { CursorPage } from './types';
|
||||
|
||||
/**
|
||||
* 页码分页列表 hook(对接后端 offset 分页 + total)。
|
||||
* 与 useCursorList(「加载更多」)并存:需要页码/跳页/共 N 条的主列表页用本 hook。
|
||||
*
|
||||
* 约定:后端 cursor 即 offset,本 hook 按 (page-1)*pageSize 计算;CursorPage.total 为符合
|
||||
* 筛选条件的总条数(供 antd Table pagination 渲染)。filters 变化自动回第 1 页重载。
|
||||
*/
|
||||
export function usePagedList<T>(
|
||||
url: string,
|
||||
filters: Record<string, unknown>,
|
||||
initialPageSize = 20,
|
||||
) {
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(initialPageSize);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const requestSeq = useRef(0);
|
||||
|
||||
const filtersKey = JSON.stringify(filters);
|
||||
|
||||
const load = useCallback(
|
||||
async (p: number, ps: number) => {
|
||||
const seq = requestSeq.current + 1;
|
||||
requestSeq.current = seq;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = { ...JSON.parse(filtersKey), limit: ps, cursor: (p - 1) * ps };
|
||||
const { data } = await api.get<CursorPage<T>>(url, { params });
|
||||
if (requestSeq.current !== seq) return; // 丢弃过期响应(快速切页/筛选)
|
||||
setItems(data.items);
|
||||
setTotal(data.total ?? 0);
|
||||
} finally {
|
||||
if (requestSeq.current === seq) setLoading(false);
|
||||
}
|
||||
},
|
||||
[url, filtersKey],
|
||||
);
|
||||
|
||||
// 筛选变化:回第 1 页重载
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
load(1, pageSize);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [load]);
|
||||
|
||||
// antd Table pagination.onChange(page, pageSize):页变或每页条数变都走这里
|
||||
const onChange = (p: number, ps: number) => {
|
||||
if (ps !== pageSize) {
|
||||
// 每页条数变 → 回第 1 页,避免越界空页
|
||||
setPageSize(ps);
|
||||
setPage(1);
|
||||
load(1, ps);
|
||||
} else {
|
||||
setPage(p);
|
||||
load(p, ps);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
loading,
|
||||
onChange,
|
||||
reload: () => load(page, pageSize), // 写操作后刷新当前页
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user