Compare commits

..

5 Commits

Author SHA1 Message Date
zzhyyyyy fff72530c6 feat(force-onboarding): 用户列表加「开启新手引导」一次性操作
点一下把该用户标记为未看过引导:Ta 下次打开 App 重走一遍新手引导,看完后端自动恢复(只触发这一次);未消费前显示「待重看」可撤销。operator 角色可见。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:07:09 +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
6 changed files with 654 additions and 0 deletions
+3
View File
@@ -7,3 +7,6 @@ next-env.d.ts
.DS_Store
data/
.run-logs/
# CLAUDE.md 本地维护、不入库(同 android/app-server 约定)
CLAUDE.md
+227
View File
@@ -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>
);
}
+4
View File
@@ -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: '审计日志' },
+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 { 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>
);
}
+31
View File
@@ -87,6 +87,28 @@ export default function UsersPage() {
});
};
// 一次性操作:点一下把该用户标记为「未看过引导」,下次打开 App 重看一遍,看完后端自动恢复(只触发这一次)。
// 用户尚未重看前可「撤销」(把 force_onboarding 置回 false),纠正误点。
const setForceOnboarding = (u: UserListItem, enable: boolean) => {
Modal.confirm({
title: enable
? `确认让用户 ${u.phone} 重看一次新手引导?`
: `撤销用户 ${u.phone} 的「重看新手引导」?`,
content: enable
? '点一下即把该用户标记为「未看过引导」:Ta 下次打开 App 会重走一遍新手引导,看完自动恢复——只触发这一次。仅对已支持该功能的 App 版本生效。'
: '该用户尚未重看,撤销后下次打开 App 不再触发。',
onOk: async () => {
try {
await api.post(`/admin/api/users/${u.id}/force-onboarding`, { enabled: enable });
message.success(enable ? '已开启:该用户下次打开 App 会重看一次' : '已撤销');
reload();
} catch (e) {
message.error(errMsg(e));
}
},
});
};
const columns: ColumnsType<UserListItem> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '手机号', dataIndex: 'phone' },
@@ -118,6 +140,15 @@ export default function UsersPage() {
{u.debug_trace_enabled ? '关调试链接' : '开调试链接'}
</a>
)}
{canStatus &&
u.status !== 'deleted' &&
(u.force_onboarding ? (
<span style={{ color: '#fa8c16' }}>
<a onClick={() => setForceOnboarding(u, false)}></a>
</span>
) : (
<a onClick={() => setForceOnboarding(u, true)}></a>
))}
</Space>
),
},
+64
View File
@@ -29,6 +29,8 @@ export interface UserListItem {
status: string;
// 调试链接权限:开了的用户在 App 比价结果页/记录页可复制 price.shaguabijia.com 的 trace 调试链接
debug_trace_enabled: boolean;
// 运营「一键开启新手引导」:true = 该用户下次打开 App 会被强制重走新手引导,走完自动取消
force_onboarding: boolean;
wechat_openid: string | null;
created_at: string;
last_login_at: string;
@@ -180,6 +182,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 +237,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;
}