Compare commits

...

12 Commits

Author SHA1 Message Date
OuYingJun1024 de58e69933 feat(admin-web): 主列表页改页码分页(usePagedList + antd pagination)
用户/提现/上报/审计日志四页从「加载更多」改为页码分页:
- 新增 usePagedList hook:页码→offset、暴露 total,配 antd Table pagination;
  与 useCursorList(加载更多)并存,详情子表/反馈页仍用后者
- CursorPage 类型加 total?
- 四页接入 pagination(页码/跳页/共 N 条/showSizeChanger);提现页移除自定义
  「每次加载」选择器,统一用 showSizeChanger(20/50/100)
- 反馈页本轮不动(后端反馈接口在 feat/feedback-iteration 迭代中)

tsc --noEmit 通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:37:38 +08:00
OuYingJun1024 a84c4570ba fix(admin-web): 用户列表列宽——手机号/昵称定宽,操作列加宽防换行
手机号/昵称原无 width,宽屏下吃满剩余空间显得失衡;操作列 fixed 230px
装不下 super_admin 的 5 个链接(详情/调金币/调现金/封禁/关调试链接)→ 换行。
- 手机号 width 130、昵称 width 150 + ellipsis(超长省略)
- 操作列 230→300 + Space wrap={false},5 链接稳定一行
- scroll.x 1100→1160 同步

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:02:56 +08:00
OuYingJun1024 b1270481aa feat(admin-web): 反馈工单页加截图列 + 排序/筛选/批量标记处理
- 截图列:缩略图 + Image.PreviewGroup 预览,URL 走 NEXT_PUBLIC_MEDIA_BASE(本地 :8770)
- 排序:ID/时间 列头服务端排序
- 筛选:状态 + 用户ID + 内容模糊 + 提交时间范围(草稿态 + 查询/重置)
- 批量:勾选 new 态 → 批量标记处理(循环 /handle + Promise.allSettled)
- 对齐用户页交互模式;依赖 app-server list_feedbacks 新增的 sort/filter 参数

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:37:22 +08:00
OuYingJun1024 8ec2e99366 fix(admin-web): 时间渲染收口到 formatUtcTime,消灭 new Date().toLocaleString
review 发现 4 处仍用 new Date(v).toLocaleString 渲染后端 UTC 口径时间戳——
正是 lib/format.ts 头注释明令消灭的反模式(SQLite 本地/非中国浏览器会差 8h,
生产靠 +00:00 偏移侥幸正确)。统一改用 formatUtcTime 按北京显示:
- admins: last_login_at
- audit-logs: created_at
- devices: completed_at
- dashboard/HomeStatsConfig: ops_stat_config.updated_at
均为 server_default now()/datetime.now(utc) 的 UTC 口径字段。
HomeStatsConfig 的「下个更新时刻」用 epoch ms + 强制 Asia/Shanghai,正确,不动。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:21:47 +08:00
ouzhou 94d550db9b feat(admin-web): 余额设值/批量、时区与复用整顿、用户列表排序筛选 (#9)
- 调金币/现金:新增「设为指定值」模式;修 InputNumber addonAfter 废弃告警;
  弹窗抽成共享组件 AdjustBalanceModals(列表页/详情页复用,内含防竞态余额拉取)
- 新增 lib/format.ts 统一金额/时间格式化:区分 UTC 字段与钱包流水(北京 wall-clock),
  修正提现详情现金流水快 8 小时、用户注册时间慢 8 小时等偏差
- 提现页:账本校验(ledger-check)改按需触发,写操作后不再重算;现金流水时区修正
- 用户详情页:加载失败 Result 兜底(不再永久转圈)+ 现金流水 tab + 调金币/现金/封禁入口
- 金币审计页:加「只看不一致」过滤;截断提示改用后端精确 truncated 旗标
- 用户列表:注册/最近登录/ID 列排序 + 渠道/昵称/时间范围筛选 + 批量封禁解封/开关调试链接

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #9
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-13 23:43:52 +08:00
marco 2eff4a71be feat(onboarding): 删「按用户强制引导」UI + 新增设备管理页
- users 页删 force_onboarding 操作 + types 字段
- 新增设备管理页(/devices): 按设备列表 + 行内「重走引导」+「全部重设」危险操作 + 菜单项

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 00:51:51 +08:00
zhuzihao c66d90a6e1 feat(force-onboarding): 用户列表加「开启新手引导」一次性操作 (#8)
点一下把该用户标记为未看过引导:Ta 下次打开 App 重走一遍新手引导,看完后端自动恢复(只触发这一次);未消费前显示「待重看」可撤销。operator 角色可见。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #8
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-10 22:26:41 +08:00
ouzhou 1717ee8b6a feat(dashboard): 首页三数共享保存+智能校验+保底;轮播种子批量操作 (#7)
- HomeStatsConfig: 三指标共享保存/立即更新;智能关系校验弹窗;真实值保底;初始基数护栏提示;saveAll 半失败合并
- HomeMarqueeSeeds: 勾选 + 批量启用/停用/删除
- 附带并行会话 WIP:users 页

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #7
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-10 22:26:36 +08:00
ouzhou 8c7e2b0220 feat(ad-audit): 新增看广告金币审计页 (#4)
- /ad-audit 复算对账页:公式快照 + reward_video/feed 发奖明细 + 实发与复算一致性校验
- 侧栏新增「金币审计」导航
- types 增加 AdCoinAuditRow / AdCoinFormula / AdCoinAudit

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #4
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-10 16:07:52 +08:00
marco 6766fe8c10 Merge pull request 'feat(debug-trace): 用户列表加「开/关调试链接」开关' (#6) from feat/debug-trace-link into main
Reviewed-on: #6
2026-06-10 15:34:50 +08:00
marco 97cdc07c4a Merge pull request 'feat: 运营后台「上报审核」页' (#5) from feat/price-report-review into main
Reviewed-on: #5
2026-06-10 14:44:32 +08:00
xianze cac9a38192 feat(price-reports): 运营后台「上报审核」页
用户上报"某平台比我们算的最低价更便宜"+截图,运营人工审核:
- price-reports/page.tsx: 列表+截图预览+通过(发1000金币)/拒绝(填理由),仿 withdraws
- layout.tsx: 侧边栏菜单加「上报审核」(FlagOutlined)
- lib/types.ts: PriceReport / PriceReportSummary DTO
- .gitignore: 加 CLAUDE.md(本地维护不入库,同 android/app-server 约定)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:45:29 +08:00
17 changed files with 2021 additions and 256 deletions
+3
View File
@@ -7,3 +7,6 @@ next-env.d.ts
.DS_Store
data/
.run-logs/
# CLAUDE.md 本地维护、不入库(同 android/app-server 约定)
CLAUDE.md
+247
View File
@@ -0,0 +1,247 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import {
Alert,
Button,
Card,
Checkbox,
DatePicker,
InputNumber,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import type { AdCoinAudit, AdCoinAuditRow } from '@/lib/types';
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 [queriedLimit, setQueriedLimit] = useState<number>(100); // 本次结果对应的 limit,截断提示里展示
const [onlyMismatch, setOnlyMismatch] = useState(false); // 只看不一致(✗)行
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,
only_mismatch: onlyMismatch || undefined,
},
});
setData(res.data);
setQueriedLimit(limit);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
}, [date, userId, scene, limit, onlyMismatch]);
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: (v: string) => formatUtcTime(v), 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}` }))}
/>
<Checkbox checked={onlyMismatch} onChange={(e) => setOnlyMismatch(e.target.checked)}>
()
</Checkbox>
<Button type="primary" onClick={load} loading={loading}>
</Button>
</Space>
{data && (
<div style={{ marginBottom: 12 }}>
<Space direction="vertical" style={{ width: '100%' }}>
{data.mismatch_count > 0 ? (
<Alert
type="error"
message={`${data.total} 条,其中 ${data.mismatch_count} 条复算与实发不一致(✗)——公式可能未生效或发奖有问题`}
/>
) : (
<Alert type="success" message={`${data.total} 条,全部按公式发放(无不一致)`} />
)}
{onlyMismatch && (
<Alert
type="info"
showIcon
message={`当前只展示不一致(✗)行;上方统计仍为全量(共 ${data.total} 条)。`}
/>
)}
{data.truncated && (
<Alert
type="warning"
showIcon
message={`展示明细已截断到 ${queriedLimit} 条,还有更多未显示——请按用户/场景/日期缩小范围,或调大「条数」。统计数字不受影响。`}
/>
)}
</Space>
</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>
);
}
+3 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import { Button, Form, Input, Modal, Select, Table, Tag, message } from 'antd';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import type { AdminInfo } from '@/lib/types';
const ROLES = [
@@ -11,7 +12,8 @@ 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 [admins, setAdmins] = useState<AdminInfo[]>([]);
+14 -13
View File
@@ -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>
);
}
+54 -1
View File
@@ -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}
+234 -87
View File
@@ -7,6 +7,7 @@ import {
Checkbox,
Col,
InputNumber,
Modal,
Popconfirm,
Radio,
Row,
@@ -18,6 +19,7 @@ import {
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 +34,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 +56,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 +129,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 +152,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 +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>
);
}
+101
View File
@@ -0,0 +1,101 @@
'use client';
import type { ColumnsType } from 'antd/es/table';
import { Button, Modal, Popconfirm, Space, Table, message } 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() {
// 后端按设备聚合、全量返回(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>
);
}
+200 -14
View File
@@ -1,23 +1,100 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import type { Key } from 'react';
import type { ColumnsType } from 'antd/es/table';
import { Button, Select, Space, Table, Tag, message } from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import {
Button,
DatePicker,
Image,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime } from '@/lib/format';
import { useCursorList } from '@/lib/useCursorList';
import type { Feedback } from '@/lib/types';
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
const { Text } = 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';
export default function FeedbacksPage() {
// 筛选草稿:点「查询」才应用(避免输入即刷新)
const [status, setStatus] = useState<string | undefined>();
const [filters, setFilters] = useState<Record<string, unknown>>({});
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 filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
const filterKey = JSON.stringify(filters);
const { items, nextCursor, loading, loadMore, reload } = useCursorList<Feedback>(
'/admin/api/feedbacks',
filters,
);
const canHandle = canDo(['operator']);
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
useEffect(() => {
setSelectedRowKeys([]);
}, [filterKey]);
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 handle = async (f: Feedback) => {
try {
@@ -29,18 +106,75 @@ export default function FeedbacksPage() {
}
};
const selectedNew = items.filter(
(f) => selectedRowKeys.includes(f.id) && f.status === 'new',
);
// 批量:循环调现有逐条 /handle 接口(各自已带审计),Promise.allSettled 汇总成败,不新增 bulk 端点
const bulkHandle = () => {
if (!selectedNew.length) {
message.warning('没有可处理的选中项(仅待处理 new 态生效)');
return;
}
Modal.confirm({
title: `确认批量标记处理 ${selectedNew.length} 条反馈?`,
content: '仅对选中的待处理(new)反馈生效',
onOk: async () => {
const results = await Promise.allSettled(
selectedNew.map((f) => api.post(`/admin/api/feedbacks/${f.id}/handle`)),
);
const ok = results.filter((r) => r.status === 'fulfilled').length;
const fail = results.length - ok;
if (fail) message.warning(`批量标记处理完成:成功 ${ok}/${results.length},失败 ${fail}`);
else message.success(`批量标记处理完成:${ok}`);
setSelectedRowKeys([]);
reload();
},
});
};
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: 'images',
width: 180,
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: 'contact', width: 140, render: (v: string) => v || '-' },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) => <Tag color={s === 'new' ? 'orange' : 'green'}>{s}</Tag>,
},
{ title: '时间', dataIndex: 'created_at', render: dt, width: 180 },
{
title: '时间',
dataIndex: 'created_at',
width: 180,
sorter: true,
sortOrder: sortOrderOf('created_at'),
render: (v: string) => formatUtcTime(v),
},
{
title: '操作',
key: 'op',
@@ -57,23 +191,75 @@ export default function FeedbacksPage() {
return (
<div>
<h2></h2>
<Space style={{ marginBottom: 16 }}>
<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: '已处理' },
]}
/>
<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>
<Button onClick={resetFilters}></Button>
</Space>
<Table rowKey="id" columns={columns} dataSource={items} loading={loading} pagination={false} />
{canHandle && selectedNew.length > 0 && (
<Space style={{ marginBottom: 12, width: '100%' }} wrap>
<span style={{ color: '#888' }}> {selectedNew.length} </span>
<Button type="primary" onClick={bulkHandle}>
</Button>
<Button type="link" onClick={() => setSelectedRowKeys([])}>
</Button>
</Space>
)}
<Table
rowKey="id"
rowSelection={
canHandle
? {
selectedRowKeys,
onChange: setSelectedRowKeys,
getCheckboxProps: (f) => ({ disabled: f.status !== 'new' }),
}
: undefined
}
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
onChange={onTableChange}
/>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
{nextCursor ? '加载更多' : '没有更多了'}
+6
View File
@@ -5,8 +5,11 @@ import { usePathname, useRouter } from 'next/navigation';
import {
DashboardOutlined,
FileSearchOutlined,
FlagOutlined,
FundProjectionScreenOutlined,
LogoutOutlined,
MessageOutlined,
MobileOutlined,
MoneyCollectOutlined,
SettingOutlined,
TeamOutlined,
@@ -21,8 +24,11 @@ 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: '/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: '审计日志' },
+325
View File
@@ -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 { 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)}`);
// 截图是 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, 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>
);
}
+152 -18
View File
@@ -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 {
Button,
Card,
Col,
Descriptions,
Modal,
Result,
Row,
Space,
Spin,
Statistic,
Table,
Tabs,
Tag,
message,
} 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 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>
);
}
+236 -59
View File
@@ -1,57 +1,100 @@
'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 { Button, DatePicker, Input, Modal, Select, Space, Table, Tag, message } 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 [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({
@@ -87,10 +130,72 @@ export default function UsersPage() {
});
};
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: '状态',
@@ -101,15 +206,29 @@ 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>
)}
@@ -133,47 +252,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>
);
}
+52 -63
View File
@@ -36,11 +36,10 @@ 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, formatWallTime, utcDayjs, utcFromNow, yuan } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type {
AuditLog,
CashTxn,
@@ -52,19 +51,12 @@ import type {
WxpayHealthCheck,
} from '@/lib/types';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
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',
@@ -161,7 +153,7 @@ 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('历史异常单');
@@ -181,6 +173,7 @@ export default function WithdrawsPage() {
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,7 +181,6 @@ 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);
@@ -209,11 +201,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<WithdrawOrder>('/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 +244,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 +280,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);
}
@@ -595,7 +595,8 @@ export default function WithdrawsPage() {
},
{ 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 },
// 现金流水是北京 wall-clock 口径,原样显示,不能当 UTC 再 +8
{ title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
];
const recentWithdrawColumns: ColumnsType<WithdrawOrder> = [
@@ -686,6 +687,16 @@ 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>
@@ -872,31 +883,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,18 +899,21 @@ 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
+201
View File
@@ -0,0 +1,201 @@
'use client';
// 调金币 / 调现金弹窗(受控)。列表页与用户详情页共用,避免两处各写一套。
// 每个弹窗自带「增减 / 设为指定值」两种模式;打开时拉一次 360 概览展示当前余额。
import { useEffect, useState } from 'react';
import { Form, Input, InputNumber, Modal, Segmented, message } 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 [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 [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>
);
}
+47
View File
@@ -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);
}
+71
View File
@@ -19,6 +19,7 @@ export interface LoginResponse {
export interface CursorPage<T> {
items: T[];
next_cursor: number | null;
total?: number | null; // offset 分页时为符合筛选条件的总条数(页码分页用);加载更多接口可能没有
}
export interface UserListItem {
@@ -34,6 +35,13 @@ export interface UserListItem {
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;
@@ -180,6 +188,42 @@ 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; // 全量条数(不受 limit/only_mismatch 影响)
mismatch_count: number; // 全量不一致条数
truncated: boolean; // 展示集是否被 limit 截断
items: AdCoinAuditRow[];
}
export interface DashboardOverview {
users: {
total: number;
@@ -200,3 +244,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;
}
+75
View File
@@ -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), // 写操作后刷新当前页
};
}