Compare commits

...

2 Commits

Author SHA1 Message Date
ouzhou a393b5c0e3 feat(config): 配置页 bool 开关控件 + 全站 antd App.useApp 迁移 (#12)
- 系统配置页支持 bool 类型(Switch 控件),供「提现自动对账」「比价期广告」等运营开关使用
- message / Modal.confirm 静态调用迁移到 App.useApp() 的 message / modal,消除 React 19
  "Static function can not consume context" 告警;providers 用 <App> 容器包裹全站

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

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #12
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-17 10:38:40 +08:00
marco 9134ea9255 feat(cps): CPS 分发与对账后台页
群/活动管理 + 生成带 sid 短链(/c/{code}) + 对账统计(点击 PV/UV + 下单/佣金)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:01:08 +08:00
18 changed files with 852 additions and 37 deletions
+1 -1
View File
@@ -2438,7 +2438,7 @@
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.19",
"resolved": "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz",
"integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==",
"cpu": [
"x64"
+2 -1
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import {
App,
Alert,
Button,
Card,
@@ -19,7 +20,6 @@ import {
Table,
Tag,
Typography,
message,
} from 'antd';
import {
FallOutlined,
@@ -269,6 +269,7 @@ function TrendChart({ points }: { points: TrendPoint[] }) {
// 广告数据页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出
// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。
export default function AdRevenuePage() {
const { message } = App.useApp();
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
const [userId, setUserId] = useState<number | null>(null);
const [adType, setAdType] = useState<string | undefined>();
+4 -3
View File
@@ -2,7 +2,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 { App, Button, Form, Input, Modal, Select, Table, Tag } from 'antd';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import type { AdminInfo } from '@/lib/types';
@@ -16,6 +16,7 @@ const ROLES = [
const dt = (v: string | null) => (v ? formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss') : '从未');
export default function AdminsPage() {
const { message, modal } = App.useApp();
const [admins, setAdmins] = useState<AdminInfo[]>([]);
const [loading, setLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
@@ -49,7 +50,7 @@ export default function AdminsPage() {
const changeRole = (a: AdminInfo, role: string) => {
if (role === a.role) return;
Modal.confirm({
modal.confirm({
title: `${a.username} 的角色改为 ${role}?`,
onOk: async () => {
try {
@@ -65,7 +66,7 @@ export default function AdminsPage() {
const toggle = (a: AdminInfo) => {
const next = a.status === 'active' ? 'disabled' : 'active';
Modal.confirm({
modal.confirm({
title: `${next === 'disabled' ? '禁用' : '启用'}管理员 ${a.username}?`,
onOk: async () => {
try {
+10 -3
View File
@@ -1,14 +1,14 @@
'use client';
import { useEffect, useState } from 'react';
import { Button, Card, Input, InputNumber, Space, Spin, Tag, Tooltip, message } from 'antd';
import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tag, Tooltip } from 'antd';
import { api, errMsg } from '@/lib/api';
interface ConfigItem {
key: string;
label: string;
group: string;
type: string; // int / int_list / dict_str_int
type: string; // int / int_list / dict_str_int / bool
help: string | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: any;
@@ -28,6 +28,7 @@ function toEdit(item: ConfigItem): any {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function fromEdit(type: string, raw: any): any {
if (type === 'int') return Number(raw);
if (type === 'bool') return Boolean(raw);
if (type === 'int_list') {
return String(raw)
.split(',')
@@ -38,6 +39,7 @@ function fromEdit(type: string, raw: any): any {
}
export default function ConfigPage() {
const { message } = App.useApp();
const [items, setItems] = useState<ConfigItem[]>([]);
const [loading, setLoading] = useState(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -109,7 +111,12 @@ export default function ConfigPage() {
)}
</div>
<Space wrap>
{item.type === 'int' ? (
{item.type === 'bool' ? (
<Switch
checked={!!edits[item.key]}
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
/>
) : item.type === 'int' ? (
<InputNumber
value={edits[item.key]}
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
+719
View File
@@ -0,0 +1,719 @@
'use client';
// CPS 优惠券分发与对账(当前仅美团)。单页 Tabs:对账统计 / 群管理 / 活动管理 / 订单明细。
// 群管理生成带 sid 的券链接 → 发群 → 对账按 sid 拉回订单/佣金归群统计。
import { useCallback, useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import {
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime, yuan } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type {
CpsActivity,
CpsGroup,
CpsGroupStat,
CpsOrder,
CpsReconcileResult,
CpsReferralLink,
CpsStats,
} from '@/lib/types';
const MT_STATUS: Record<string, { text: string; color: string }> = {
'2': { text: '已付款', color: 'blue' },
'3': { text: '已完成', color: 'cyan' },
'4': { text: '已取消', color: 'red' },
'5': { text: '风控', color: 'orange' },
'6': { text: '已结算', color: 'green' },
};
const LINK_TYPE_NAME: Record<string, string> = {
'1': 'H5 长链',
'2': '短链(微信可发)',
'3': 'deeplink(唤起 App)',
};
const fmtRate = (r: string | null) => (r ? `${Number(r) / 100}%` : '-');
export default function CpsPage() {
return (
<div>
<h2>CPS </h2>
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
sid: sid , sid
()
</Typography.Paragraph>
<Tabs
defaultActiveKey="stats"
destroyInactiveTabPane
items={[
{ key: 'stats', label: '对账统计', children: <StatsTab /> },
{ key: 'groups', label: '群管理', children: <GroupsTab /> },
{ key: 'activities', label: '活动管理', children: <ActivitiesTab /> },
{ key: 'orders', label: '订单明细', children: <OrdersTab /> },
]}
/>
</div>
);
}
// ───────────── 对账统计 ─────────────
function StatsTab() {
const [days, setDays] = useState(30);
const [data, setData] = useState<CpsStats | null>(null);
const [loading, setLoading] = useState(false);
const [reconciling, setReconciling] = useState(false);
const canReconcile = canDo(['finance']);
const load = useCallback(async (d: number) => {
setLoading(true);
try {
const resp = await api.get<CpsStats>('/admin/api/cps/stats', { params: { days: d } });
setData(resp.data);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load(days);
}, [days, load]);
const reconcile = async () => {
setReconciling(true);
try {
const resp = await api.post<CpsReconcileResult>('/admin/api/cps/orders/reconcile', null, {
params: { days },
});
const r = resp.data;
message.success(`对账完成:拉取 ${r.fetched} 单(新增 ${r.inserted}、更新 ${r.updated})`);
load(days);
} catch (e) {
message.error(errMsg(e));
} finally {
setReconciling(false);
}
};
const columns: ColumnsType<CpsGroupStat> = [
{ title: '群', dataIndex: 'name', width: 160, ellipsis: true },
{
title: 'sid',
dataIndex: 'sid',
width: 130,
render: (v: string | null) => (v ? <Typography.Text copyable>{v}</Typography.Text> : '-'),
},
{ title: '人数', dataIndex: 'member_count', width: 70, render: (v) => v ?? '-' },
{ title: '点击', dataIndex: 'click_pv', width: 70 },
{ title: '独立访客', dataIndex: 'click_uv', width: 80 },
{ title: '有效单', dataIndex: 'order_count', width: 70 },
{
title: '取消/风控',
dataIndex: 'canceled_count',
width: 85,
render: (v: number) => (v ? <span style={{ color: '#cf1322' }}>{v}</span> : 0),
},
{
title: '点击→下单',
key: 'rate',
width: 90,
render: (_, r) =>
r.click_uv ? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%` : '-',
},
{ title: '成交额', dataIndex: 'gmv_cents', width: 110, render: (v: number) => yuan(v) },
{
title: '预估佣金',
dataIndex: 'est_commission_cents',
width: 110,
sorter: (a, b) => a.est_commission_cents - b.est_commission_cents,
render: (v: number) => <b>{yuan(v)}</b>,
},
{
title: '已结算佣金',
dataIndex: 'settled_commission_cents',
width: 110,
render: (v: number) => <span style={{ color: '#3f8600' }}>{yuan(v)}</span>,
},
];
return (
<div>
<Space style={{ marginBottom: 16 }} wrap>
<span></span>
<Select
value={days}
onChange={setDays}
style={{ width: 120 }}
options={[
{ value: 7, label: '近 7 天' },
{ value: 30, label: '近 30 天' },
{ value: 90, label: '近 90 天' },
]}
/>
{canReconcile && (
<Button type="primary" loading={reconciling} onClick={reconcile}>
()
</Button>
)}
<Typography.Text type="secondary">
,
</Typography.Text>
</Space>
<Space size="large" style={{ marginBottom: 16 }} wrap>
<Card size="small">
<Statistic title="有效订单数" value={data?.total_order_count ?? 0} />
</Card>
<Card size="small">
<Statistic
title="预估佣金"
value={(data?.total_est_commission_cents ?? 0) / 100}
precision={2}
prefix="¥"
/>
</Card>
<Card size="small">
<Statistic
title="已结算佣金"
value={(data?.total_settled_commission_cents ?? 0) / 100}
precision={2}
prefix="¥"
valueStyle={{ color: '#3f8600' }}
/>
</Card>
</Space>
<Table
rowKey={(r) => r.sid ?? `gid-${r.group_id ?? 'none'}`}
columns={columns}
dataSource={data?.groups ?? []}
loading={loading}
pagination={false}
scroll={{ x: 1120 }}
/>
</div>
);
}
// ───────────── 群管理 ─────────────
function GroupsTab() {
const [keyword, setKeyword] = useState('');
const [applied, setApplied] = useState<Record<string, unknown>>({});
const { items, total, page, pageSize, loading, onChange, reload } = usePagedList<CpsGroup>(
'/admin/api/cps/groups',
applied,
);
const canManage = canDo(['operator']);
const [editing, setEditing] = useState<CpsGroup | null>(null); // null=不开,{} 新建用单独标志
const [createOpen, setCreateOpen] = useState(false);
const [linkGroup, setLinkGroup] = useState<CpsGroup | null>(null);
const columns: ColumnsType<CpsGroup> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '群名', dataIndex: 'name', width: 180, ellipsis: true },
{
title: 'sid',
dataIndex: 'sid',
width: 150,
render: (v: string) => <Typography.Text copyable>{v}</Typography.Text>,
},
{ title: '人数', dataIndex: 'member_count', width: 80, render: (v) => v ?? '-' },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s}</Tag>,
},
{ title: '备注', dataIndex: 'remark', width: 160, ellipsis: true, render: (v) => v || '-' },
{ title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) },
{
title: '操作',
key: 'op',
fixed: 'right',
width: 170,
render: (_, g) => (
<Space onClick={(e) => e.stopPropagation()}>
{canManage && <a onClick={() => setLinkGroup(g)}></a>}
{canManage && <a onClick={() => setEditing(g)}></a>}
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16 }} wrap>
<Input
placeholder="群名/sid"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => setApplied({ keyword: keyword || undefined })}
allowClear
style={{ width: 180 }}
/>
<Button type="primary" onClick={() => setApplied({ keyword: keyword || undefined })}>
</Button>
{canManage && (
<Button onClick={() => setCreateOpen(true)}></Button>
)}
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 个群`,
onChange,
}}
scroll={{ x: 1050 }}
/>
<GroupFormModal
open={createOpen}
group={null}
onClose={() => setCreateOpen(false)}
onDone={reload}
/>
<GroupFormModal
open={!!editing}
group={editing}
onClose={() => setEditing(null)}
onDone={reload}
/>
<ReferralLinkModal group={linkGroup} onClose={() => setLinkGroup(null)} />
</div>
);
}
function GroupFormModal({
open,
group,
onClose,
onDone,
}: {
open: boolean;
group: CpsGroup | null;
onClose: () => void;
onDone: () => void;
}) {
const [form] = Form.useForm();
const isEdit = !!group;
useEffect(() => {
if (open) {
if (group) {
form.setFieldsValue({
name: group.name,
member_count: group.member_count,
remark: group.remark,
status: group.status,
});
} else {
form.resetFields();
}
}
}, [open, group, form]);
const submit = async () => {
const v = await form.validateFields();
try {
if (isEdit && group) {
await api.patch(`/admin/api/cps/groups/${group.id}`, v);
message.success('已更新群');
} else {
await api.post('/admin/api/cps/groups', v);
message.success('已新建群');
}
onClose();
onDone();
} catch (e) {
message.error(errMsg(e));
}
};
return (
<Modal
title={isEdit ? '编辑群' : '新建群'}
open={open}
onOk={submit}
onCancel={onClose}
destroyOnHidden
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="群名" rules={[{ required: true, message: '请输入群名' }]}>
<Input placeholder="如:宝妈优惠群1" />
</Form.Item>
{!isEdit && (
<Form.Item
name="sid"
label="sid(渠道标识,留空自动生成 g+ID)"
rules={[{ pattern: /^[A-Za-z0-9]+$/, message: '仅字母和数字' }]}
>
<Input placeholder="留空自动生成;或自定义如 baoma1" maxLength={64} />
</Form.Item>
)}
<Form.Item name="member_count" label="群人数(用于估算转化率)">
<InputNumber style={{ width: '100%' }} min={0} placeholder="如 200" />
</Form.Item>
{isEdit && (
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'active', label: 'active(启用)' },
{ value: 'archived', label: 'archived(归档)' },
]}
/>
</Form.Item>
)}
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={256} />
</Form.Item>
</Form>
</Modal>
);
}
function ReferralLinkModal({ group, onClose }: { group: CpsGroup | null; onClose: () => void }) {
const [activities, setActivities] = useState<CpsActivity[]>([]);
const [activityId, setActivityId] = useState<number | undefined>();
const [result, setResult] = useState<CpsReferralLink | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!group) {
setActivityId(undefined);
setResult(null);
return;
}
api
.get('/admin/api/cps/activities', { params: { status: 'active', limit: 100 } })
.then((r) => setActivities(r.data.items as CpsActivity[]))
.catch((e) => message.error(errMsg(e)));
}, [group]);
const generate = async () => {
if (!group || !activityId) {
message.warning('请选择活动');
return;
}
setLoading(true);
try {
const resp = await api.post<CpsReferralLink>('/admin/api/cps/referral-link', {
group_id: group.id,
activity_id: activityId,
});
setResult(resp.data);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
};
return (
<Modal
title={`生成券链接 - ${group?.name ?? ''}(sid=${group?.sid ?? ''})`}
open={!!group}
onCancel={onClose}
onOk={generate}
okText="生成"
confirmLoading={loading}
destroyOnHidden
width={640}
>
<Form layout="vertical">
<Form.Item label="选择活动" required>
<Select
placeholder="选要推广的活动"
value={activityId}
onChange={setActivityId}
options={activities.map((a) => ({
value: a.id,
label: `${a.name}${a.act_id ? ` (actId=${a.act_id})` : ''}`,
}))}
notFoundContent="无活动,请先到「活动管理」新建"
/>
</Form.Item>
</Form>
{result && (
<div style={{ marginTop: 8 }}>
<Typography.Text strong>👇 ( sid={result.sid},):</Typography.Text>
<div
style={{
marginTop: 8,
padding: 12,
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 6,
}}
>
<Typography.Text
copyable={{ text: result.redirect_url }}
style={{ fontSize: 15, wordBreak: 'break-all' }}
>
{result.redirect_url}
</Typography.Text>
</div>
{!result.redirect_url.startsWith('http') && (
<Typography.Text type="warning" style={{ fontSize: 12 }}>
CPS_REDIRECT_BASE,; .env
</Typography.Text>
)}
<Typography.Paragraph type="secondary" style={{ marginTop: 14, marginBottom: 4, fontSize: 12 }}>
(/,):
</Typography.Paragraph>
{Object.entries(result.link_map).map(([type, url]) => (
<div key={type} style={{ marginBottom: 4 }}>
<Tag>{LINK_TYPE_NAME[type] ?? type}</Tag>
<Typography.Text
copyable={{ text: url }}
style={{ fontSize: 12, color: '#999', wordBreak: 'break-all' }}
>
{url.length > 56 ? `${url.slice(0, 56)}` : url}
</Typography.Text>
</div>
))}
</div>
)}
</Modal>
);
}
// ───────────── 活动管理 ─────────────
function ActivitiesTab() {
const { items, total, page, pageSize, loading, onChange, reload } = usePagedList<CpsActivity>(
'/admin/api/cps/activities',
{},
);
const canManage = canDo(['operator']);
const [createOpen, setCreateOpen] = useState(false);
const columns: ColumnsType<CpsActivity> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '平台', dataIndex: 'platform', width: 90, render: (v: string) => <Tag>{v}</Tag> },
{ title: '活动名', dataIndex: 'name', width: 220, ellipsis: true },
{
title: 'actId',
dataIndex: 'act_id',
width: 120,
render: (v: string | null) => v || '-',
},
{
title: 'productViewSign',
dataIndex: 'product_view_sign',
width: 160,
ellipsis: true,
render: (v: string | null) => v || '-',
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s}</Tag>,
},
{ title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) },
];
return (
<div>
{canManage && (
<Space style={{ marginBottom: 16 }}>
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
</Space>
)}
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 个活动`,
onChange,
}}
scroll={{ x: 1000 }}
/>
<ActivityFormModal open={createOpen} onClose={() => setCreateOpen(false)} onDone={reload} />
</div>
);
}
function ActivityFormModal({
open,
onClose,
onDone,
}: {
open: boolean;
onClose: () => void;
onDone: () => void;
}) {
const [form] = Form.useForm();
useEffect(() => {
if (open) form.resetFields();
}, [open, form]);
const submit = async () => {
const v = await form.validateFields();
if (!v.act_id && !v.product_view_sign) {
message.warning('actId 与 productViewSign 至少填一个');
return;
}
try {
await api.post('/admin/api/cps/activities', v);
message.success('已新建活动');
onClose();
onDone();
} catch (e) {
message.error(errMsg(e));
}
};
return (
<Modal title="新建活动" open={open} onOk={submit} onCancel={onClose} destroyOnHidden>
<Form form={form} layout="vertical" initialValues={{ platform: 'meituan' }}>
<Form.Item name="name" label="活动名" rules={[{ required: true }]}>
<Input placeholder="如:点我领取大额红包" />
</Form.Item>
<Form.Item name="platform" label="平台">
<Select
options={[
{ value: 'meituan', label: '美团(已支持)' },
{ value: 'taobao', label: '淘宝(待接入)', disabled: true },
{ value: 'jd', label: '京东(待接入)', disabled: true },
]}
/>
</Form.Item>
<Form.Item name="act_id" label="actId(美团活动物料 ID,推荐)">
<Input placeholder="美团联盟「我要推广-活动推广」第一列 ID,如 792" maxLength={64} />
</Form.Item>
<Form.Item name="product_view_sign" label="productViewSign(商品券,与 actId 二选一)">
<Input maxLength={128} />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={256} />
</Form.Item>
</Form>
</Modal>
);
}
// ───────────── 订单明细 ─────────────
function OrdersTab() {
const [sid, setSid] = useState('');
const [status, setStatus] = useState<string | undefined>();
const [applied, setApplied] = useState<Record<string, unknown>>({});
const { items, total, page, pageSize, loading, onChange } = usePagedList<CpsOrder>(
'/admin/api/cps/orders',
applied,
);
const columns: ColumnsType<CpsOrder> = [
{
title: '订单号',
dataIndex: 'order_id',
width: 200,
ellipsis: true,
render: (v: string) => <Typography.Text copyable>{v}</Typography.Text>,
},
{ title: 'sid', dataIndex: 'sid', width: 120, render: (v) => v || '-' },
{ title: '商品', dataIndex: 'product_name', width: 200, ellipsis: true, render: (v) => v || '-' },
{
title: '成交额',
dataIndex: 'pay_price_cents',
width: 100,
render: (v: number | null) => (v != null ? yuan(v) : '-'),
},
{
title: '佣金',
dataIndex: 'commission_cents',
width: 90,
render: (v: number | null) => (v != null ? yuan(v) : '-'),
},
{ title: '佣金率', dataIndex: 'commission_rate', width: 80, render: fmtRate },
{
title: '状态',
dataIndex: 'mt_status',
width: 90,
render: (s: string | null) => {
const m = s ? MT_STATUS[s] : null;
return m ? <Tag color={m.color}>{m.text}</Tag> : s || '-';
},
},
{ title: '支付时间', dataIndex: 'pay_time', width: 150, render: (v) => formatUtcTime(v) },
];
const search = () => setApplied({ sid: sid || undefined, mt_status: status });
return (
<div>
<Space style={{ marginBottom: 16 }} wrap>
<Input
placeholder="sid"
value={sid}
onChange={(e) => setSid(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 150 }}
/>
<Select
placeholder="状态"
value={status}
onChange={setStatus}
allowClear
style={{ width: 120 }}
options={Object.entries(MT_STATUS).map(([v, m]) => ({ value: v, label: m.text }))}
/>
<Button type="primary" onClick={search}>
</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange,
}}
scroll={{ x: 1030 }}
/>
</div>
);
}
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import {
App,
Button,
Form,
Input,
@@ -12,7 +13,6 @@ import {
Spin,
Switch,
Table,
message,
} from 'antd';
import { api, errMsg } from '@/lib/api';
@@ -51,6 +51,7 @@ const yuan = (cents: number) => (cents / 100).toFixed(2);
/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「数据大盘」页的一个区块。 */
export default function HomeMarqueeSeeds() {
const { message } = App.useApp();
const [seeds, setSeeds] = useState<Seed[]>([]);
const [loading, setLoading] = useState(true);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]); // 批量操作选中行
+3 -3
View File
@@ -2,12 +2,12 @@
import { useEffect, useState } from 'react';
import {
App,
Button,
Card,
Checkbox,
Col,
InputNumber,
Modal,
Popconfirm,
Radio,
Row,
@@ -16,7 +16,6 @@ import {
Spin,
Tag,
Tooltip,
message,
} from 'antd';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
@@ -249,6 +248,7 @@ function buildBody(metric: string, e: Edit, immediate: boolean): Record<string,
/** 首页三门面数字(帮助用户/完成比价/累计节省)的展示模式配置。作为「数据大盘」页的一个区块。 */
export default function HomeStatsConfig() {
const { message, modal } = App.useApp();
const [items, setItems] = useState<StatItem[]>([]);
const [loading, setLoading] = useState(true);
const [edits, setEdits] = useState<Record<string, Edit>>({});
@@ -321,7 +321,7 @@ export default function HomeStatsConfig() {
// 智能校验:按下后三者大概会呈现的关系是否合理,不合理则弹窗劝阻、确认才提交。
const rel = checkRelations(items, edits);
if (rel && rel.warnings.length > 0) {
Modal.confirm({
modal.confirm({
title: '数据关系校验',
width: 480,
okText: '仍要这样设置',
+3 -2
View File
@@ -1,7 +1,7 @@
'use client';
import type { ColumnsType } from 'antd/es/table';
import { Button, Modal, Popconfirm, Space, Table, message } from 'antd';
import { App, Button, Popconfirm, Space, Table } from 'antd';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime } from '@/lib/format';
@@ -13,6 +13,7 @@ const dt = (v: string) => formatUtcTime(v);
const EMPTY: Record<string, unknown> = {};
export default function DevicesPage() {
const { message, modal } = App.useApp();
// 后端按设备聚合、全量返回(next_cursor 恒 null),故无筛选/加载更多。
const { items, loading, reload } = useCursorList<DeviceOnboardingItem>(
'/admin/api/onboarding/devices',
@@ -31,7 +32,7 @@ export default function DevicesPage() {
};
const resetAll = () => {
Modal.confirm({
modal.confirm({
title: '确认全部重设新手引导?',
content:
'清空所有设备的引导完成记录,库里所有用户下次打开 App 都会重走一遍新手引导。此操作不可逆。',
+3 -3
View File
@@ -5,17 +5,16 @@ import type { Key } from 'react';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import {
App,
Button,
DatePicker,
Image,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
@@ -34,6 +33,7 @@ const mediaUrl = (p: string) => (p.startsWith('http') ? p : MEDIA_BASE + p);
type SortField = 'id' | 'created_at';
export default function FeedbacksPage() {
const { message, modal } = App.useApp();
// 筛选草稿:点「查询」才应用(避免输入即刷新)
const [status, setStatus] = useState<string | undefined>();
const [userId, setUserId] = useState('');
@@ -114,7 +114,7 @@ export default function FeedbacksPage() {
message.warning('没有可处理的选中项(仅待处理 new 态生效)');
return;
}
Modal.confirm({
modal.confirm({
title: `确认批量标记处理 ${selectedNew.length} 条反馈?`,
content: '仅对选中的待处理(new)反馈生效',
onOk: async () => {
+2
View File
@@ -12,6 +12,7 @@ import {
MobileOutlined,
MoneyCollectOutlined,
SettingOutlined,
ShareAltOutlined,
TeamOutlined,
UserOutlined,
} from '@ant-design/icons';
@@ -29,6 +30,7 @@ const MENU = [
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
{ key: '/ad-revenue', icon: <BarChartOutlined />, label: '广告数据' },
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS 分发' },
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
+3 -2
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import {
App,
Button,
Card,
Image,
@@ -13,7 +14,6 @@ import {
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
@@ -57,6 +57,7 @@ function statusTag(status: string) {
}
export default function PriceReportsPage() {
const { message, modal } = App.useApp();
const [activeStatus, setActiveStatus] = useState('pending');
const [summary, setSummary] = useState<PriceReportSummary | null>(null);
const [rejecting, setRejecting] = useState<PriceReport | null>(null);
@@ -88,7 +89,7 @@ export default function PriceReportsPage() {
};
const approve = (r: PriceReport) => {
Modal.confirm({
modal.confirm({
title: '确认通过该上报?',
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
content: (
+3 -3
View File
@@ -4,11 +4,11 @@ import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import type { ColumnsType } from 'antd/es/table';
import {
App,
Button,
Card,
Col,
Descriptions,
Modal,
Result,
Row,
Space,
@@ -17,7 +17,6 @@ import {
Table,
Tabs,
Tag,
message,
} from 'antd';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
@@ -27,6 +26,7 @@ import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModa
import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
export default function UserDetailPage() {
const { message, modal } = App.useApp();
const params = useParams<{ id: string }>();
const uid = Number(params.id);
const [data, setData] = useState<UserOverview | null>(null);
@@ -56,7 +56,7 @@ export default function UserDetailPage() {
const toggleStatus = () => {
if (!data) return;
const next = data.user.status === 'active' ? 'disabled' : 'active';
Modal.confirm({
modal.confirm({
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${data.user.phone}?`,
onOk: async () => {
try {
+6 -5
View File
@@ -5,7 +5,7 @@ import type { Key } from 'react';
import { useRouter } from 'next/navigation';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import { Button, DatePicker, Input, Modal, Select, Space, Table, Tag, message } from 'antd';
import { App, Button, DatePicker, Input, Select, Space, Table, Tag } from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
@@ -22,6 +22,7 @@ type SortField = 'id' | 'created_at' | 'last_login_at';
export default function UsersPage() {
const router = useRouter();
const { message, modal } = App.useApp();
// 筛选草稿:点「查询」才应用(避免输入即刷新);状态/渠道/日期一并随查询提交
const [phone, setPhone] = useState('');
@@ -97,7 +98,7 @@ export default function UsersPage() {
const toggleStatus = (u: UserListItem) => {
const next = u.status === 'active' ? 'disabled' : 'active';
Modal.confirm({
modal.confirm({
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${u.phone}?`,
onOk: async () => {
try {
@@ -113,7 +114,7 @@ export default function UsersPage() {
const toggleDebugTrace = (u: UserListItem) => {
const next = !u.debug_trace_enabled;
Modal.confirm({
modal.confirm({
title: `确认${next ? '开启' : '关闭'}用户 ${u.phone} 的调试链接权限?`,
content: next
? '开启后,该用户在 App 比价结果页/记录页可复制 trace 调试链接发给开发排障'
@@ -156,7 +157,7 @@ export default function UsersPage() {
message.warning(`没有可${target === 'disabled' ? '封禁(active)' : '解封(disabled)'}的选中用户`);
return;
}
Modal.confirm({
modal.confirm({
title: `确认${label} ${targets.length} 个用户?`,
content: target === 'disabled' ? '仅对选中的 active 用户生效' : '仅对选中的 disabled 用户生效',
okButtonProps: { danger: target === 'disabled' },
@@ -177,7 +178,7 @@ export default function UsersPage() {
message.warning('没有需要变更的选中用户');
return;
}
Modal.confirm({
modal.confirm({
title: `确认${label} ${targets.length} 个用户?`,
onOk: () =>
runBulk(targets, label, (u) =>
+7 -6
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react';
import type { Key } from 'react';
import {
App,
Alert,
Button,
Card,
@@ -22,7 +23,6 @@ import {
Tag,
Timeline,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
@@ -169,6 +169,7 @@ function bulkResultText(action: string, result: WithdrawBulkResult) {
}
export default function WithdrawsPage() {
const { message, modal } = App.useApp();
const [activeStatus, setActiveStatus] = useState('reviewing');
const [summary, setSummary] = useState<WithdrawSummary | null>(null);
const [health, setHealth] = useState<WxpayHealthCheck | null>(null);
@@ -305,7 +306,7 @@ export default function WithdrawsPage() {
};
const approve = (o: WithdrawOrder) => {
Modal.confirm({
modal.confirm({
title: `确认通过并打款 ${yuan(o.amount_cents)}?`,
content: (
<div>
@@ -368,7 +369,7 @@ export default function WithdrawsPage() {
}
const amount = selectedReviewing.reduce((sum, item) => sum + item.amount_cents, 0);
let confirmText = '';
Modal.confirm({
modal.confirm({
title: `确认批量通过 ${selectedReviewing.length} 笔提现?`,
content: (
<Space direction="vertical" style={{ width: '100%' }}>
@@ -406,7 +407,7 @@ export default function WithdrawsPage() {
message.warning('请选择打款中的提现单');
return;
}
Modal.confirm({
modal.confirm({
title: `确认批量刷新 ${selectedPending.length} 笔打款状态?`,
content: '会逐笔向微信查单,并按最新状态归一化。',
okText: '批量刷新查单',
@@ -428,7 +429,7 @@ export default function WithdrawsPage() {
};
const retry = (o: WithdrawOrder) => {
Modal.confirm({
modal.confirm({
title: `重新查询 ${o.out_bill_no} 的微信状态?`,
content: '会按微信最新状态归一化为成功、失败退款或撤销未确认。',
okText: '刷新查单',
@@ -450,7 +451,7 @@ export default function WithdrawsPage() {
};
const reconcile = () => {
Modal.confirm({
modal.confirm({
title: '批量对账?',
content: '扫描超过 15 分钟仍处于打款中的提现单,逐单向微信查实并归一化。',
okText: '开始对账',
+2 -1
View File
@@ -2,13 +2,14 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, Form, Input, message } from 'antd';
import { App, Button, Card, Form, Input } from 'antd';
import { api, errMsg } from '@/lib/api';
import { setAuth } from '@/lib/auth';
import type { LoginResponse } from '@/lib/types';
export default function LoginPage() {
const router = useRouter();
const { message } = App.useApp();
const [loading, setLoading] = useState(false);
const onFinish = async (values: { username: string; password: string }) => {
+4 -2
View File
@@ -5,7 +5,7 @@
// 必须在引入 antd 之前 import。
import '@ant-design/v5-patch-for-react-19';
import { AntdRegistry } from '@ant-design/nextjs-registry';
import { ConfigProvider } from 'antd';
import { App, ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
export function Providers({ children }: { children: React.ReactNode }) {
@@ -23,7 +23,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
},
}}
>
{children}
{/* antd <App> 容器:让 message/notification/Modal 能通过 App.useApp() 读到 ConfigProvider
的动态主题/locale,消除 "Static function can not consume context" 告警(全站生效) */}
<App>{children}</App>
</ConfigProvider>
</AntdRegistry>
);
+3 -1
View File
@@ -3,7 +3,7 @@
// 调金币 / 调现金弹窗(受控)。列表页与用户详情页共用,避免两处各写一套。
// 每个弹窗自带「增减 / 设为指定值」两种模式;打开时拉一次 360 概览展示当前余额。
import { useEffect, useState } from 'react';
import { Form, Input, InputNumber, Modal, Segmented, message } from 'antd';
import { App, Form, Input, InputNumber, Modal, Segmented } from 'antd';
import { api, errMsg } from '@/lib/api';
import { yuan } from '@/lib/format';
import type { UserOverview } from '@/lib/types';
@@ -47,6 +47,7 @@ function useBalances(userId: number | undefined, open: boolean) {
}
export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) {
const { message } = App.useApp();
const [form] = Form.useForm();
const [mode, setMode] = useState<'delta' | 'set'>('delta');
const balances = useBalances(user?.id, open);
@@ -110,6 +111,7 @@ export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) {
}
export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) {
const { message } = App.useApp();
const [form] = Form.useForm();
const [mode, setMode] = useState<'delta' | 'set'>('delta');
const balances = useBalances(user?.id, open);
+75
View File
@@ -303,3 +303,78 @@ export interface PriceReportSummary {
rejected: number;
total: number;
}
// ===== CPS 分发与对账 =====
export interface CpsGroup {
id: number;
sid: string; // 美团二级渠道追踪位,每群唯一
name: string;
member_count: number | null;
status: string; // active / archived
remark: string | null;
created_at: string;
}
export interface CpsActivity {
id: number;
platform: string; // meituan / taobao / jd
name: string;
act_id: string | null; // 美团活动物料 ID
product_view_sign: string | null;
status: string;
remark: string | null;
created_at: string;
}
export interface CpsOrder {
id: number;
order_id: string;
sid: string | null;
act_id: string | null;
pay_price_cents: number | null;
commission_cents: number | null;
commission_rate: string | null; // "300"=3% "10"=0.1%
mt_status: string | null; // 2付款 3完成 4取消 5风控 6结算
invalid_reason: string | null;
product_name: string | null;
pay_time: string | null;
}
export interface CpsReferralLink {
redirect_url: string; // ★ 我们的群发短链(/c/{code}),发群用这个,点击经我们统计
code: string;
sid: string;
group_name: string;
activity_name: string;
target_url: string; // 实际 302 跳转的美团短链
link_map: Record<string, string>; // 美团原始 1长链/2短链/3deeplink(参考)
}
export interface CpsReconcileResult {
fetched: number;
inserted: number;
updated: number;
pages: number;
}
export interface CpsGroupStat {
group_id: number | null; // null = 未归群的 sid
sid: string | null;
name: string;
member_count: number | null;
click_pv: number; // 点击总次数
click_uv: number; // 独立点击(ip+ua 近似去重)
order_count: number; // 有效单(不含取消/风控)
settled_count: number;
canceled_count: number;
gmv_cents: number;
est_commission_cents: number;
settled_commission_cents: number;
}
export interface CpsStats {
groups: CpsGroupStat[];
total_order_count: number;
total_est_commission_cents: number;
total_settled_commission_cents: number;
}