Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a393b5c0e3 | |||
| 9134ea9255 |
Generated
+1
-1
@@ -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"
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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[]>([]); // 批量操作选中行
|
||||
|
||||
@@ -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: '仍要这样设置',
|
||||
|
||||
@@ -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 都会重走一遍新手引导。此操作不可逆。',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: '审计日志' },
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,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 }) => {
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user