Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fff72530c6 | |||
| 8c7e2b0220 | |||
| 6766fe8c10 | |||
| 97cdc07c4a | |||
| cac9a38192 |
@@ -7,3 +7,6 @@ next-env.d.ts
|
||||
.DS_Store
|
||||
data/
|
||||
.run-logs/
|
||||
|
||||
# CLAUDE.md 本地维护、不入库(同 android/app-server 约定)
|
||||
CLAUDE.md
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import type { AdCoinAudit, AdCoinAuditRow } from '@/lib/types';
|
||||
|
||||
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
|
||||
|
||||
const SCENE_TAG: Record<string, { color: string; label: string }> = {
|
||||
reward_video: { color: 'blue', label: '看视频' },
|
||||
feed: { color: 'purple', label: '信息流' },
|
||||
};
|
||||
|
||||
const STATUS_TAG: Record<string, string> = {
|
||||
granted: 'green',
|
||||
capped: 'orange',
|
||||
ecpm_missing: 'red',
|
||||
};
|
||||
|
||||
const fmtFactorRange = (a: number | null, b: number | null) => {
|
||||
if (a == null) return '-';
|
||||
return a === b || b == null ? String(a) : `${a}→${b}`;
|
||||
};
|
||||
|
||||
const fmtIndexRange = (a: number | null, b: number | null) => {
|
||||
if (a == null) return '-';
|
||||
return a === b ? String(a) : `${a}–${b}`;
|
||||
};
|
||||
|
||||
export default function AdAuditPage() {
|
||||
const [date, setDate] = useState<Dayjs>(dayjs());
|
||||
const [userId, setUserId] = useState<number | null>(null);
|
||||
const [scene, setScene] = useState<string | undefined>();
|
||||
const [limit, setLimit] = useState<number>(100);
|
||||
const [data, setData] = useState<AdCoinAudit | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<AdCoinAudit>('/admin/api/ad-coin-audit', {
|
||||
params: {
|
||||
date: date.format('YYYY-MM-DD'),
|
||||
user_id: userId ?? undefined,
|
||||
scene: scene ?? undefined,
|
||||
limit,
|
||||
},
|
||||
});
|
||||
setData(res.data);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [date, userId, scene, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// 仅首次自动拉今天;之后由「查询」按钮触发
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<AdCoinAuditRow> = [
|
||||
{
|
||||
title: '场景',
|
||||
dataIndex: 'scene',
|
||||
width: 90,
|
||||
render: (s: string) => {
|
||||
const t = SCENE_TAG[s] ?? { color: 'default', label: s };
|
||||
return <Tag color={t.color}>{t.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '时间', dataIndex: 'created_at', render: dt, width: 175 },
|
||||
{ title: '用户', dataIndex: 'user_id', width: 70 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (s: string) => <Tag color={STATUS_TAG[s] ?? 'default'}>{s}</Tag>,
|
||||
},
|
||||
{ title: 'eCPM', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' },
|
||||
{
|
||||
title: '因子1',
|
||||
dataIndex: 'ecpm_factor',
|
||||
width: 70,
|
||||
render: (v: number | null) => v ?? '-',
|
||||
},
|
||||
{ title: '份数', dataIndex: 'units', width: 60 },
|
||||
{
|
||||
title: 'LT累计条数',
|
||||
key: 'lt_index',
|
||||
width: 100,
|
||||
render: (_: unknown, r: AdCoinAuditRow) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
|
||||
},
|
||||
{
|
||||
title: '因子2',
|
||||
key: 'lt_factor',
|
||||
width: 90,
|
||||
render: (_: unknown, r: AdCoinAuditRow) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end),
|
||||
},
|
||||
{
|
||||
title: '应发金币',
|
||||
dataIndex: 'expected_coin',
|
||||
width: 100,
|
||||
render: (v: number) => <b>{v}</b>,
|
||||
},
|
||||
{ title: '实发金币', dataIndex: 'actual_coin', width: 100 },
|
||||
{
|
||||
title: '一致',
|
||||
dataIndex: 'matched',
|
||||
width: 70,
|
||||
render: (m: boolean) =>
|
||||
m ? <Tag color="green">✓</Tag> : <Tag color="red">✗ 不符</Tag>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>看广告金币审计</h2>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
把「看视频赚金币」和「比价信息流广告」的发奖记录,用与正式发奖相同的公式复算一遍,核对实发金币是否按公式计算。纯只读对账。
|
||||
</Typography.Paragraph>
|
||||
|
||||
{data && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Typography.Text strong>金币公式:</Typography.Text>{' '}
|
||||
<Typography.Text code>{data.formula.description}</Typography.Text>
|
||||
<div style={{ marginTop: 8, color: '#666', fontSize: 13 }}>
|
||||
<div>
|
||||
eCPM 口径:{data.formula.ecpm_unit},计算时 ÷100 转元;金币:元 ={' '}
|
||||
{data.formula.coin_per_yuan}:1;信息流每 {data.formula.feed_unit_seconds} 秒折 1 条。两个点位「LT累计条数」各自独立计数。
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
因子1(按 eCPM 元判档,阈值单位:元):
|
||||
{data.formula.ecpm_factor_tiers
|
||||
.map(([f, lo, hi]) => `${lo}~${hi ?? '∞'}元→${f}`)
|
||||
.join(' | ')}
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
因子2(LT 累计条数):
|
||||
{data.formula.lt_factor_tiers
|
||||
.map(([f, lo, hi]) => `${lo}${hi != null && hi !== lo ? `~${hi}` : ''}→${f}`)
|
||||
.join(' | ')}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<DatePicker value={date} onChange={(d) => d && setDate(d)} allowClear={false} />
|
||||
<InputNumber
|
||||
placeholder="用户 ID(可空)"
|
||||
value={userId ?? undefined}
|
||||
onChange={(v) => setUserId(v ?? null)}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="场景(全部)"
|
||||
value={scene}
|
||||
onChange={setScene}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'reward_video', label: '看视频' },
|
||||
{ value: 'feed', label: '信息流' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={limit}
|
||||
onChange={setLimit}
|
||||
style={{ width: 120 }}
|
||||
options={[50, 100, 200, 500].map((n) => ({ value: n, label: `${n} 条` }))}
|
||||
/>
|
||||
<Button type="primary" onClick={load} loading={loading}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{data && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{data.mismatch_count > 0 ? (
|
||||
<Alert
|
||||
type="error"
|
||||
message={`共 ${data.total} 条,其中 ${data.mismatch_count} 条复算与实发不一致(✗)——公式可能未生效或发奖有问题`}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="success"
|
||||
message={`共 ${data.total} 条,全部按公式发放(无不一致)`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table
|
||||
rowKey={(r) => `${r.scene}-${r.record_id}`}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 1100 }}
|
||||
rowClassName={(r) => (r.matched ? '' : 'row-mismatch')}
|
||||
/>
|
||||
<style jsx global>{`
|
||||
.row-mismatch > td {
|
||||
background: #fff1f0 !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
FileSearchOutlined,
|
||||
FlagOutlined,
|
||||
FundProjectionScreenOutlined,
|
||||
LogoutOutlined,
|
||||
MessageOutlined,
|
||||
MoneyCollectOutlined,
|
||||
@@ -22,7 +24,9 @@ const MENU = [
|
||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
|
||||
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
|
||||
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
|
||||
{ key: '/ad-audit', icon: <FundProjectionScreenOutlined />, label: '金币审计' },
|
||||
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
|
||||
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
|
||||
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { useCursorList } from '@/lib/useCursorList';
|
||||
import type { PriceReport, PriceReportSummary } from '@/lib/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// 后端 created_at/reviewed_at 存的是北京 wall-clock(naive,同 savings/comparison),
|
||||
// 直接本地解析、不加 Z(否则会差 8h)。
|
||||
const dt = (v: string | null) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-');
|
||||
const yuan = (c: number | null) => (c == null ? '-' : `¥${(c / 100).toFixed(2)}`);
|
||||
|
||||
// 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。
|
||||
const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE || '';
|
||||
const mediaUrl = (p: string) => (p.startsWith('http') ? p : MEDIA_BASE + p);
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending: 'gold',
|
||||
approved: 'green',
|
||||
rejected: 'default',
|
||||
};
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
};
|
||||
const STATUS_TABS = [
|
||||
{ key: 'pending', label: '待审核' },
|
||||
{ key: 'approved', label: '已通过' },
|
||||
{ key: 'rejected', label: '已拒绝' },
|
||||
{ key: 'all', label: '全部' },
|
||||
];
|
||||
const REJECT_TEMPLATES = ['截图不清晰', '商品不匹配', '价格不属实', '平台不支持', '重复上报', '其他原因'];
|
||||
|
||||
function statusTag(status: string) {
|
||||
return <Tag color={STATUS_COLOR[status]}>{STATUS_LABEL[status] || status}</Tag>;
|
||||
}
|
||||
|
||||
export default function PriceReportsPage() {
|
||||
const [activeStatus, setActiveStatus] = useState('pending');
|
||||
const [summary, setSummary] = useState<PriceReportSummary | null>(null);
|
||||
const [rejecting, setRejecting] = useState<PriceReport | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
const filters: Record<string, unknown> = {};
|
||||
if (activeStatus !== 'all') filters.status = activeStatus;
|
||||
|
||||
const { items, nextCursor, loading, loadMore, reload } = useCursorList<PriceReport>(
|
||||
'/admin/api/price-reports',
|
||||
filters,
|
||||
);
|
||||
|
||||
const canReview = canDo(['operator']);
|
||||
|
||||
const loadSummary = async () => {
|
||||
try {
|
||||
const { data } = await api.get<PriceReportSummary>('/admin/api/price-reports/summary');
|
||||
setSummary(data);
|
||||
} catch {
|
||||
/* 统计失败不阻塞列表 */
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
loadSummary();
|
||||
}, []);
|
||||
|
||||
const refreshAfterChange = () => {
|
||||
reload();
|
||||
loadSummary();
|
||||
};
|
||||
|
||||
const approve = (r: PriceReport) => {
|
||||
Modal.confirm({
|
||||
title: '确认通过该上报?',
|
||||
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
|
||||
content: (
|
||||
<div>
|
||||
<div>用户 #{r.user_id}</div>
|
||||
<div>门店: {r.store_name || '-'}</div>
|
||||
<div>
|
||||
上报价: {yuan(r.reported_price_cents)}({r.reported_platform_name})
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: '#8c8c8c' }}>
|
||||
通过后给用户发放 1000 金币奖励,请先确认截图属实。
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
okText: '通过并发金币',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.post(`/admin/api/price-reports/${r.id}/approve`);
|
||||
message.success('已通过,已发放 1000 金币');
|
||||
refreshAfterChange();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const confirmReject = async () => {
|
||||
if (!rejecting) return;
|
||||
const reason = rejectReason.trim();
|
||||
if (!reason) {
|
||||
message.warning('请填写拒绝理由');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post(`/admin/api/price-reports/${rejecting.id}/reject`, { reason });
|
||||
message.success('已拒绝');
|
||||
setRejecting(null);
|
||||
setRejectReason('');
|
||||
refreshAfterChange();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<PriceReport> = [
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'user_id',
|
||||
width: 90,
|
||||
render: (v: number) => <Text strong>#{v}</Text>,
|
||||
},
|
||||
{
|
||||
title: '门店 / 菜品',
|
||||
key: 'store',
|
||||
width: 200,
|
||||
render: (_: unknown, r: PriceReport) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text>{r.store_name || '-'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{r.dish_summary || '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '原最低价 → 上报价',
|
||||
key: 'price',
|
||||
width: 220,
|
||||
render: (_: unknown, r: PriceReport) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{r.original_platform_name || '原'}: {yuan(r.original_price_cents)}
|
||||
</Text>
|
||||
<Text strong style={{ color: '#fa541c' }}>
|
||||
{r.reported_platform_name}: {yuan(r.reported_price_cents)}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '截图证明',
|
||||
dataIndex: 'images',
|
||||
width: 180,
|
||||
render: (imgs: string[]) =>
|
||||
imgs && imgs.length ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space size={4} wrap>
|
||||
{imgs.map((src, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
src={mediaUrl(src)}
|
||||
width={44}
|
||||
height={44}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<Text type="secondary">无</Text>
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: statusTag },
|
||||
{
|
||||
title: '提交时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 150,
|
||||
render: (v: string) => dt(v),
|
||||
},
|
||||
{
|
||||
title: '操作 / 结果',
|
||||
key: 'op',
|
||||
fixed: 'right',
|
||||
width: 200,
|
||||
render: (_: unknown, r: PriceReport) => {
|
||||
if (r.status === 'pending') {
|
||||
if (!canReview) return <Text type="secondary">无审核权限</Text>;
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={() => approve(r)}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={() => {
|
||||
setRejecting(r);
|
||||
setRejectReason('');
|
||||
}}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
if (r.status === 'approved') {
|
||||
return <Text type="success">已发 {r.reward_coins ?? 0} 金币</Text>;
|
||||
}
|
||||
if (r.status === 'rejected') {
|
||||
return <Text type="secondary">拒绝: {r.reject_reason || '-'}</Text>;
|
||||
}
|
||||
return <Text type="secondary">-</Text>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
上报审核
|
||||
</Typography.Title>
|
||||
<Text type="secondary">
|
||||
用户上报「某平台更低价」+ 截图,人工核实后通过发放 1000 金币奖励、拒绝需填理由。用户在 app
|
||||
内轮询自动看到结果。
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Space size="large" style={{ marginBottom: 16 }}>
|
||||
<Statistic title="待审核" value={summary.pending} valueStyle={{ color: '#faad14' }} />
|
||||
<Statistic title="已通过" value={summary.approved} valueStyle={{ color: '#52c41a' }} />
|
||||
<Statistic title="已拒绝" value={summary.rejected} />
|
||||
<Statistic title="合计" value={summary.total} />
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Tabs
|
||||
activeKey={activeStatus}
|
||||
onChange={setActiveStatus}
|
||||
items={STATUS_TABS.map((t) => ({ key: t.key, label: t.label }))}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||
{nextCursor ? '加载更多' : '没有更多了'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="拒绝上报"
|
||||
open={!!rejecting}
|
||||
okText="确认拒绝"
|
||||
okButtonProps={{ danger: true, icon: <CloseCircleOutlined /> }}
|
||||
onOk={confirmReject}
|
||||
onCancel={() => {
|
||||
setRejecting(null);
|
||||
setRejectReason('');
|
||||
}}
|
||||
>
|
||||
{rejecting && (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>
|
||||
用户 #{rejecting.user_id} · {rejecting.store_name || '-'}
|
||||
</Text>
|
||||
<Space wrap>
|
||||
{REJECT_TEMPLATES.map((tpl) => (
|
||||
<Button size="small" key={tpl} onClick={() => setRejectReason(tpl)}>
|
||||
{tpl}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
maxLength={200}
|
||||
value={rejectReason}
|
||||
placeholder="请输入拒绝理由,用户端记录页会看到"
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user