Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea211fdf45 | |||
| 8c7e2b0220 | |||
| 6766fe8c10 | |||
| 97cdc07c4a | |||
| cac9a38192 |
@@ -7,3 +7,6 @@ next-env.d.ts
|
||||
.DS_Store
|
||||
data/
|
||||
.run-logs/
|
||||
|
||||
# CLAUDE.md 本地维护、不入库(同 android/app-server 约定)
|
||||
CLAUDE.md
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import type { AdCoinAudit, AdCoinAuditRow } from '@/lib/types';
|
||||
|
||||
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
|
||||
|
||||
const SCENE_TAG: Record<string, { color: string; label: string }> = {
|
||||
reward_video: { color: 'blue', label: '看视频' },
|
||||
feed: { color: 'purple', label: '信息流' },
|
||||
};
|
||||
|
||||
const STATUS_TAG: Record<string, string> = {
|
||||
granted: 'green',
|
||||
capped: 'orange',
|
||||
ecpm_missing: 'red',
|
||||
};
|
||||
|
||||
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}`;
|
||||
};
|
||||
|
||||
export default function AdAuditPage() {
|
||||
const [date, setDate] = useState<Dayjs>(dayjs());
|
||||
const [userId, setUserId] = useState<number | null>(null);
|
||||
const [scene, setScene] = useState<string | undefined>();
|
||||
const [limit, setLimit] = useState<number>(100);
|
||||
const [data, setData] = useState<AdCoinAudit | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<AdCoinAudit>('/admin/api/ad-coin-audit', {
|
||||
params: {
|
||||
date: date.format('YYYY-MM-DD'),
|
||||
user_id: userId ?? undefined,
|
||||
scene: scene ?? undefined,
|
||||
limit,
|
||||
},
|
||||
});
|
||||
setData(res.data);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [date, userId, scene, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// 仅首次自动拉今天;之后由「查询」按钮触发
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<AdCoinAuditRow> = [
|
||||
{
|
||||
title: '场景',
|
||||
dataIndex: 'scene',
|
||||
width: 90,
|
||||
render: (s: string) => {
|
||||
const t = SCENE_TAG[s] ?? { color: 'default', label: s };
|
||||
return <Tag color={t.color}>{t.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '时间', dataIndex: 'created_at', render: dt, width: 175 },
|
||||
{ title: '用户', dataIndex: 'user_id', width: 70 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (s: string) => <Tag color={STATUS_TAG[s] ?? 'default'}>{s}</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: AdCoinAuditRow) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
|
||||
},
|
||||
{
|
||||
title: '因子2',
|
||||
key: 'lt_factor',
|
||||
width: 90,
|
||||
render: (_: unknown, r: AdCoinAuditRow) => 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: 70,
|
||||
render: (m: boolean) =>
|
||||
m ? <Tag color="green">✓</Tag> : <Tag color="red">✗ 不符</Tag>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>看广告金币审计</h2>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
把「看视频赚金币」和「比价信息流广告」的发奖记录,用与正式发奖相同的公式复算一遍,核对实发金币是否按公式计算。纯只读对账。
|
||||
</Typography.Paragraph>
|
||||
|
||||
{data && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text strong>金币公式:</Typography.Text>{' '}
|
||||
<Typography.Text code>{data.formula.description}</Typography.Text>
|
||||
<div style={{ marginTop: 8, color: '#666', fontSize: 13 }}>
|
||||
<div>
|
||||
eCPM 口径:{data.formula.ecpm_unit},计算时 ÷100 转元;金币:元 ={' '}
|
||||
{data.formula.coin_per_yuan}:1;信息流每 {data.formula.feed_unit_seconds} 秒折 1 条。两个点位「LT累计条数」各自独立计数。
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
因子1(按 eCPM 元判档,阈值单位:元):
|
||||
{data.formula.ecpm_factor_tiers
|
||||
.map(([f, lo, hi]) => `${lo}~${hi ?? '∞'}元→${f}`)
|
||||
.join(' | ')}
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
因子2(LT 累计条数):
|
||||
{data.formula.lt_factor_tiers
|
||||
.map(([f, lo, hi]) => `${lo}${hi != null && hi !== lo ? `~${hi}` : ''}→${f}`)
|
||||
.join(' | ')}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<DatePicker value={date} onChange={(d) => d && setDate(d)} allowClear={false} />
|
||||
<InputNumber
|
||||
placeholder="用户 ID(可空)"
|
||||
value={userId ?? undefined}
|
||||
onChange={(v) => setUserId(v ?? null)}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="场景(全部)"
|
||||
value={scene}
|
||||
onChange={setScene}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'reward_video', label: '看视频' },
|
||||
{ value: 'feed', label: '信息流' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={limit}
|
||||
onChange={setLimit}
|
||||
style={{ width: 120 }}
|
||||
options={[50, 100, 200, 500].map((n) => ({ value: n, label: `${n} 条` }))}
|
||||
/>
|
||||
<Button type="primary" onClick={load} loading={loading}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{data && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{data.mismatch_count > 0 ? (
|
||||
<Alert
|
||||
type="error"
|
||||
message={`共 ${data.total} 条,其中 ${data.mismatch_count} 条复算与实发不一致(✗)——公式可能未生效或发奖有问题`}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="success"
|
||||
message={`共 ${data.total} 条,全部按公式发放(无不一致)`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table
|
||||
rowKey={(r) => `${r.scene}-${r.record_id}`}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 1100 }}
|
||||
rowClassName={(r) => (r.matched ? '' : 'row-mismatch')}
|
||||
/>
|
||||
<style jsx global>{`
|
||||
.row-mismatch > td {
|
||||
background: #fff1f0 !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ const yuan = (cents: number) => (cents / 100).toFixed(2);
|
||||
export default function HomeMarqueeSeeds() {
|
||||
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 +71,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 +178,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 +259,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 +269,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}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Checkbox,
|
||||
Col,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Row,
|
||||
@@ -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,6 +151,101 @@ 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 [items, setItems] = useState<StatItem[]>([]);
|
||||
@@ -188,52 +284,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 +352,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 +371,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 +432,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 +563,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
|
||||
@@ -474,21 +615,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 +626,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
FileSearchOutlined,
|
||||
FlagOutlined,
|
||||
FundProjectionScreenOutlined,
|
||||
LogoutOutlined,
|
||||
MessageOutlined,
|
||||
MoneyCollectOutlined,
|
||||
@@ -22,7 +24,9 @@ const MENU = [
|
||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
|
||||
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
|
||||
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
|
||||
{ key: '/ad-audit', icon: <FundProjectionScreenOutlined />, label: '金币审计' },
|
||||
{ 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 {
|
||||
Button,
|
||||
Card,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { useCursorList } from '@/lib/useCursorList';
|
||||
import type { PriceReport, PriceReportSummary } from '@/lib/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// 后端 created_at/reviewed_at 存的是北京 wall-clock(naive,同 savings/comparison),
|
||||
// 直接本地解析、不加 Z(否则会差 8h)。
|
||||
const dt = (v: string | null) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-');
|
||||
const yuan = (c: number | null) => (c == null ? '-' : `¥${(c / 100).toFixed(2)}`);
|
||||
|
||||
// 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。
|
||||
const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE || '';
|
||||
const mediaUrl = (p: string) => (p.startsWith('http') ? p : MEDIA_BASE + p);
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending: 'gold',
|
||||
approved: 'green',
|
||||
rejected: 'default',
|
||||
};
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
};
|
||||
const STATUS_TABS = [
|
||||
{ key: 'pending', label: '待审核' },
|
||||
{ key: 'approved', label: '已通过' },
|
||||
{ key: 'rejected', label: '已拒绝' },
|
||||
{ key: 'all', label: '全部' },
|
||||
];
|
||||
const REJECT_TEMPLATES = ['截图不清晰', '商品不匹配', '价格不属实', '平台不支持', '重复上报', '其他原因'];
|
||||
|
||||
function statusTag(status: string) {
|
||||
return <Tag color={STATUS_COLOR[status]}>{STATUS_LABEL[status] || status}</Tag>;
|
||||
}
|
||||
|
||||
export default function PriceReportsPage() {
|
||||
const [activeStatus, setActiveStatus] = useState('pending');
|
||||
const [summary, setSummary] = useState<PriceReportSummary | null>(null);
|
||||
const [rejecting, setRejecting] = useState<PriceReport | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
const filters: Record<string, unknown> = {};
|
||||
if (activeStatus !== 'all') filters.status = activeStatus;
|
||||
|
||||
const { items, nextCursor, loading, loadMore, reload } = useCursorList<PriceReport>(
|
||||
'/admin/api/price-reports',
|
||||
filters,
|
||||
);
|
||||
|
||||
const canReview = canDo(['operator']);
|
||||
|
||||
const loadSummary = async () => {
|
||||
try {
|
||||
const { data } = await api.get<PriceReportSummary>('/admin/api/price-reports/summary');
|
||||
setSummary(data);
|
||||
} catch {
|
||||
/* 统计失败不阻塞列表 */
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
loadSummary();
|
||||
}, []);
|
||||
|
||||
const refreshAfterChange = () => {
|
||||
reload();
|
||||
loadSummary();
|
||||
};
|
||||
|
||||
const approve = (r: PriceReport) => {
|
||||
Modal.confirm({
|
||||
title: '确认通过该上报?',
|
||||
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
|
||||
content: (
|
||||
<div>
|
||||
<div>用户 #{r.user_id}</div>
|
||||
<div>门店: {r.store_name || '-'}</div>
|
||||
<div>
|
||||
上报价: {yuan(r.reported_price_cents)}({r.reported_platform_name})
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: '#8c8c8c' }}>
|
||||
通过后给用户发放 1000 金币奖励,请先确认截图属实。
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
okText: '通过并发金币',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.post(`/admin/api/price-reports/${r.id}/approve`);
|
||||
message.success('已通过,已发放 1000 金币');
|
||||
refreshAfterChange();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const confirmReject = async () => {
|
||||
if (!rejecting) return;
|
||||
const reason = rejectReason.trim();
|
||||
if (!reason) {
|
||||
message.warning('请填写拒绝理由');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post(`/admin/api/price-reports/${rejecting.id}/reject`, { reason });
|
||||
message.success('已拒绝');
|
||||
setRejecting(null);
|
||||
setRejectReason('');
|
||||
refreshAfterChange();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<PriceReport> = [
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'user_id',
|
||||
width: 90,
|
||||
render: (v: number) => <Text strong>#{v}</Text>,
|
||||
},
|
||||
{
|
||||
title: '门店 / 菜品',
|
||||
key: 'store',
|
||||
width: 200,
|
||||
render: (_: unknown, r: PriceReport) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text>{r.store_name || '-'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{r.dish_summary || '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '原最低价 → 上报价',
|
||||
key: 'price',
|
||||
width: 220,
|
||||
render: (_: unknown, r: PriceReport) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{r.original_platform_name || '原'}: {yuan(r.original_price_cents)}
|
||||
</Text>
|
||||
<Text strong style={{ color: '#fa541c' }}>
|
||||
{r.reported_platform_name}: {yuan(r.reported_price_cents)}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '截图证明',
|
||||
dataIndex: 'images',
|
||||
width: 180,
|
||||
render: (imgs: string[]) =>
|
||||
imgs && imgs.length ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space size={4} wrap>
|
||||
{imgs.map((src, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
src={mediaUrl(src)}
|
||||
width={44}
|
||||
height={44}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<Text type="secondary">无</Text>
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: statusTag },
|
||||
{
|
||||
title: '提交时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 150,
|
||||
render: (v: string) => dt(v),
|
||||
},
|
||||
{
|
||||
title: '操作 / 结果',
|
||||
key: 'op',
|
||||
fixed: 'right',
|
||||
width: 200,
|
||||
render: (_: unknown, r: PriceReport) => {
|
||||
if (r.status === 'pending') {
|
||||
if (!canReview) return <Text type="secondary">无审核权限</Text>;
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={() => approve(r)}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={() => {
|
||||
setRejecting(r);
|
||||
setRejectReason('');
|
||||
}}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
if (r.status === 'approved') {
|
||||
return <Text type="success">已发 {r.reward_coins ?? 0} 金币</Text>;
|
||||
}
|
||||
if (r.status === 'rejected') {
|
||||
return <Text type="secondary">拒绝: {r.reject_reason || '-'}</Text>;
|
||||
}
|
||||
return <Text type="secondary">-</Text>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
上报审核
|
||||
</Typography.Title>
|
||||
<Text type="secondary">
|
||||
用户上报「某平台更低价」+ 截图,人工核实后通过发放 1000 金币奖励、拒绝需填理由。用户在 app
|
||||
内轮询自动看到结果。
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Space size="large" style={{ marginBottom: 16 }}>
|
||||
<Statistic title="待审核" value={summary.pending} valueStyle={{ color: '#faad14' }} />
|
||||
<Statistic title="已通过" value={summary.approved} valueStyle={{ color: '#52c41a' }} />
|
||||
<Statistic title="已拒绝" value={summary.rejected} />
|
||||
<Statistic title="合计" value={summary.total} />
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Tabs
|
||||
activeKey={activeStatus}
|
||||
onChange={setActiveStatus}
|
||||
items={STATUS_TABS.map((t) => ({ key: t.key, label: t.label }))}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||
{nextCursor ? '加载更多' : '没有更多了'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="拒绝上报"
|
||||
open={!!rejecting}
|
||||
okText="确认拒绝"
|
||||
okButtonProps={{ danger: true, icon: <CloseCircleOutlined /> }}
|
||||
onOk={confirmReject}
|
||||
onCancel={() => {
|
||||
setRejecting(null);
|
||||
setRejectReason('');
|
||||
}}
|
||||
>
|
||||
{rejecting && (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>
|
||||
用户 #{rejecting.user_id} · {rejecting.store_name || '-'}
|
||||
</Text>
|
||||
<Space wrap>
|
||||
{REJECT_TEMPLATES.map((tpl) => (
|
||||
<Button size="small" key={tpl} onClick={() => setRejectReason(tpl)}>
|
||||
{tpl}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
maxLength={200}
|
||||
value={rejectReason}
|
||||
placeholder="请输入拒绝理由,用户端记录页会看到"
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,9 +18,10 @@ import {
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { useCursorList } from '@/lib/useCursorList';
|
||||
import type { UserListItem } from '@/lib/types';
|
||||
import type { UserListItem, UserOverview } from '@/lib/types';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = { active: 'green', disabled: 'red', deleted: 'default' };
|
||||
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
|
||||
|
||||
export default function UsersPage() {
|
||||
const router = useRouter();
|
||||
@@ -37,6 +38,20 @@ export default function UsersPage() {
|
||||
|
||||
const [coinUser, setCoinUser] = useState<UserListItem | null>(null);
|
||||
const [coinForm] = Form.useForm();
|
||||
const [cashUser, setCashUser] = useState<UserListItem | null>(null);
|
||||
const [cashForm] = Form.useForm();
|
||||
// 弹窗里展示该用户当前余额:列表行本身不带余额,开弹窗时拉一次 360 概览
|
||||
const [balances, setBalances] = useState<{ coin: number; cashCents: number } | null>(null);
|
||||
|
||||
const openAdjust = (u: UserListItem, kind: 'coin' | 'cash') => {
|
||||
setBalances(null);
|
||||
if (kind === 'coin') setCoinUser(u);
|
||||
else setCashUser(u);
|
||||
api
|
||||
.get<UserOverview>(`/admin/api/users/${u.id}`)
|
||||
.then((r) => setBalances({ coin: r.data.coin_balance, cashCents: r.data.cash_balance_cents }))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const search = () => setFilters({ phone: phone || undefined, status });
|
||||
|
||||
@@ -52,6 +67,27 @@ export default function UsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 发/扣现金:输入元,×100 转分后调后端(主要给无现金用户发钱直接测试提现)
|
||||
const submitCash = async () => {
|
||||
const v = await cashForm.validateFields();
|
||||
const amountCents = Math.round(Number(v.amount_yuan) * 100);
|
||||
if (amountCents === 0) {
|
||||
message.warning('金额不能为 0(且不小于 1 分)');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post(`/admin/api/users/${cashUser!.id}/cash`, {
|
||||
amount_cents: amountCents,
|
||||
reason: v.reason,
|
||||
});
|
||||
message.success(`已${amountCents > 0 ? '发放' : '扣减'} ¥${(Math.abs(amountCents) / 100).toFixed(2)} 现金`);
|
||||
setCashUser(null);
|
||||
cashForm.resetFields();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatus = (u: UserListItem) => {
|
||||
const next = u.status === 'active' ? 'disabled' : 'active';
|
||||
Modal.confirm({
|
||||
@@ -109,7 +145,8 @@ export default function UsersPage() {
|
||||
render: (_: unknown, u: UserListItem) => (
|
||||
<Space>
|
||||
<a onClick={() => router.push(`/users/${u.id}`)}>详情</a>
|
||||
{canCoins && <a onClick={() => setCoinUser(u)}>调金币</a>}
|
||||
{canCoins && <a onClick={() => openAdjust(u, 'coin')}>调金币</a>}
|
||||
{canCoins && <a onClick={() => openAdjust(u, 'cash')}>调现金</a>}
|
||||
{canStatus && u.status !== 'deleted' && (
|
||||
<a onClick={() => toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'}</a>
|
||||
)}
|
||||
@@ -163,8 +200,11 @@ export default function UsersPage() {
|
||||
open={!!coinUser}
|
||||
onOk={submitCoins}
|
||||
onCancel={() => setCoinUser(null)}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<p style={{ color: '#888', marginBottom: 12 }}>
|
||||
当前余额:金币 {balances ? balances.coin : '…'} | 现金 {balances ? yuan(balances.cashCents) : '…'}
|
||||
</p>
|
||||
<Form form={coinForm} layout="vertical">
|
||||
<Form.Item name="amount" label="金币变动(正=增加,负=扣减)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
@@ -174,6 +214,30 @@ export default function UsersPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`调现金 - ${cashUser?.phone ?? ''}`}
|
||||
open={!!cashUser}
|
||||
onOk={submitCash}
|
||||
onCancel={() => setCashUser(null)}
|
||||
destroyOnHidden
|
||||
>
|
||||
<p style={{ color: '#888', marginBottom: 12 }}>
|
||||
当前余额:金币 {balances ? balances.coin : '…'} | 现金 {balances ? yuan(balances.cashCents) : '…'}
|
||||
</p>
|
||||
<Form form={cashForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="amount_yuan"
|
||||
label="现金变动(元,正=发放,负=扣减;不可扣成负)"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} step={0.01} precision={2} addonAfter="元" />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,6 +180,41 @@ export interface AuditLog {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdCoinAuditRow {
|
||||
scene: string; // reward_video / feed
|
||||
record_id: number;
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
status: string; // granted / capped / ecpm_missing
|
||||
ecpm: string | null;
|
||||
ecpm_factor: number | null;
|
||||
units: number;
|
||||
lt_index_start: number | null;
|
||||
lt_index_end: number | null;
|
||||
lt_factor_start: number | null;
|
||||
lt_factor_end: number | null;
|
||||
expected_coin: number;
|
||||
actual_coin: number;
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
export interface AdCoinFormula {
|
||||
description: string;
|
||||
coin_per_yuan: number;
|
||||
ecpm_unit: string;
|
||||
feed_unit_seconds: number;
|
||||
ecpm_factor_tiers: [number, number, number | null][];
|
||||
lt_factor_tiers: [number, number, number | null][];
|
||||
}
|
||||
|
||||
export interface AdCoinAudit {
|
||||
date: string;
|
||||
formula: AdCoinFormula;
|
||||
total: number;
|
||||
mismatch_count: number;
|
||||
items: AdCoinAuditRow[];
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
users: {
|
||||
total: number;
|
||||
@@ -200,3 +235,30 @@ export interface DashboardOverview {
|
||||
feedback: { new: number };
|
||||
cps: { available: boolean; note: string };
|
||||
}
|
||||
|
||||
export interface PriceReport {
|
||||
id: number;
|
||||
user_id: number;
|
||||
comparison_record_id: number | null;
|
||||
store_name: string | null;
|
||||
dish_summary: string | null;
|
||||
original_platform_id: string | null;
|
||||
original_platform_name: string | null;
|
||||
original_price_cents: number | null;
|
||||
reported_platform_id: string;
|
||||
reported_platform_name: string;
|
||||
reported_price_cents: number;
|
||||
images: string[];
|
||||
status: string; // pending / approved / rejected
|
||||
reject_reason: string | null;
|
||||
reward_coins: number | null;
|
||||
reviewed_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PriceReportSummary {
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user