Files
shaguabijia-admin-web/src/app/(main)/cps/page.tsx
T
marco 1a6e590a42 feat(cps): 群对账详情页折线图(点击/独立/复制趋势,3/7/14/30天+小时)
- 对账统计页群名可点进群详情页 /cps/groups/[id]
- @ant-design/plots 折线图,4 指标多线,Segmented 切时间范围,y 轴 0 基线
- 数据稀疏靠后端补零保证折线连续/x 轴铺满

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:31:37 +08:00

964 lines
30 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
// CPS 优惠券分发与对账。平台:美团(actId+sid,完整对账) / 淘宝(淘口令) / 京东(链接)。
// 淘宝/京东无 API → 只统计点击,对账字段显示 "-"。单页 Tabs:对账统计/群管理/活动管理/订单明细。
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import type { ColumnsType } from 'antd/es/table';
import {
Button,
Card,
Checkbox,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
Upload,
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,
CpsReferralLinks,
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 PLATFORM_LABEL: Record<string, string> = { meituan: '美团', taobao: '淘宝', jd: '京东' };
const PLATFORM_COLOR: Record<string, string> = { meituan: 'gold', taobao: 'volcano', jd: 'red' };
const PLATFORM_OPTIONS = [
{ label: '美团', value: 'meituan' },
{ label: '淘宝', value: 'taobao' },
{ label: '京东', value: 'jd' },
];
const fmtRate = (r: string | null) => (r ? `${Number(r) / 100}%` : '-');
const DASH = <span style={{ color: '#bbb' }}>-</span>;
const platformTags = (ps: string[] | undefined) =>
(ps || []).map((p) => (
<Tag key={p} color={PLATFORM_COLOR[p]} style={{ marginInlineEnd: 4 }}>
{PLATFORM_LABEL[p] || p}
</Tag>
));
export default function CpsPage() {
return (
<div>
<h2>CPS </h2>
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
(actId + sid,/) / () / () API,
<b></b>,- /c/xxx
</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: 140,
ellipsis: true,
fixed: 'left',
render: (v: string, r) =>
r.group_id ? <Link href={`/cps/groups/${r.group_id}`}>{v}</Link> : v,
},
{ title: '平台', key: 'platforms', width: 130, render: (_, r) => platformTags(r.platforms) },
{ title: '人数', dataIndex: 'member_count', width: 64, render: (v) => v ?? '-' },
{ title: '点击', dataIndex: 'click_pv', width: 64 },
{ title: '独立', dataIndex: 'click_uv', width: 64 },
{
title: '复制',
dataIndex: 'copy_pv',
width: 64,
render: (v: number) => (v ? v : DASH),
},
{
title: '有效单',
dataIndex: 'order_count',
width: 70,
render: (v: number | null) => (v == null ? DASH : v),
},
{
title: '取消/风控',
dataIndex: 'canceled_count',
width: 80,
render: (v: number | null) => (v == null ? DASH : v ? <span style={{ color: '#cf1322' }}>{v}</span> : 0),
},
{
title: '点击→下单',
key: 'rate',
width: 88,
render: (_, r) =>
r.order_count == null ? DASH : r.click_uv ? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%` : '-',
},
{
title: '成交额',
dataIndex: 'gmv_cents',
width: 96,
render: (v: number | null) => (v == null ? DASH : yuan(v)),
},
{
title: '预估佣金',
dataIndex: 'est_commission_cents',
width: 100,
sorter: (a, b) => (a.est_commission_cents || 0) - (b.est_commission_cents || 0),
render: (v: number | null) => (v == null ? DASH : <b>{yuan(v)}</b>),
},
{
title: '已结算佣金',
dataIndex: 'settled_commission_cents',
width: 100,
render: (v: number | null) => (v == null ? DASH : <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: 1200 }}
/>
</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);
const [createOpen, setCreateOpen] = useState(false);
const [linkGroup, setLinkGroup] = useState<CpsGroup | null>(null);
const delGroup = async (g: CpsGroup) => {
try {
await api.delete(`/admin/api/cps/groups/${g.id}`);
message.success('已删除群');
reload();
} catch (e) {
message.error(errMsg(e));
}
};
const columns: ColumnsType<CpsGroup> = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '群名', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '平台', key: 'platforms', width: 140, render: (_, g) => platformTags(g.platforms) },
{
title: 'sid',
dataIndex: 'sid',
width: 130,
render: (v: string | null) => (v ? <Typography.Text copyable>{v}</Typography.Text> : DASH),
},
{ title: '人数', dataIndex: 'member_count', width: 70, render: (v) => v ?? '-' },
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s}</Tag>,
},
{ title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) },
{
title: '操作',
key: 'op',
fixed: 'right',
width: 200,
render: (_, g) => (
<Space onClick={(e) => e.stopPropagation()}>
{canManage && <a onClick={() => setLinkGroup(g)}></a>}
{canManage && <a onClick={() => setEditing(g)}></a>}
{canManage && (
<Popconfirm
title="删除这个群?"
description="已发出的链接仍可用,但该群不再计入统计。"
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => delGroup(g)}
>
<a style={{ color: '#cf1322' }}></a>
</Popconfirm>
)}
</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: 1090 }}
/>
<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;
const watchPlatforms = (Form.useWatch('platforms', form) as string[] | undefined) || [];
const hasMeituan = watchPlatforms.includes('meituan');
useEffect(() => {
if (open) {
if (group) {
form.setFieldsValue({
name: group.name,
platforms: group.platforms,
member_count: group.member_count,
remark: group.remark,
status: group.status,
});
} else {
form.resetFields();
form.setFieldsValue({ platforms: ['meituan'] });
}
}
}, [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>
<Form.Item
name="platforms"
label="这个群发哪些平台的券(可多选)"
rules={[{ required: true, message: '至少选一个平台' }]}
>
<Checkbox.Group options={PLATFORM_OPTIONS} />
</Form.Item>
{hasMeituan && !isEdit && (
<Form.Item
name="sid"
label="美团 sid(渠道标识,留空自动生成 g+ID)"
rules={[{ pattern: /^[A-Za-z0-9]+$/, message: '仅字母和数字' }]}
>
<Input placeholder="留空自动生成;或自定义如 baoma1" maxLength={64} />
</Form.Item>
)}
{hasMeituan && isEdit && (
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
sid:{group?.sid || '(无,保存后如缺会在生成美团链接时报错)'}
</Typography.Text>
)}
<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 [activityIds, setActivityIds] = useState<number[]>([]);
const [result, setResult] = useState<CpsReferralLinks | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!group) {
setActivityIds([]);
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 groupActivities = activities.filter((a) => (group?.platforms || []).includes(a.platform));
const generate = async () => {
if (!group || !activityIds.length) {
message.warning('请勾选要生成的活动');
return;
}
setLoading(true);
try {
const resp = await api.post<CpsReferralLinks>('/admin/api/cps/referral-links', {
group_id: group.id,
activity_ids: activityIds,
});
setResult(resp.data);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
};
return (
<Modal
title={`生成发群链接 - ${group?.name ?? ''}`}
open={!!group}
onCancel={onClose}
onOk={generate}
okText="生成"
confirmLoading={loading}
destroyOnHidden
width={660}
>
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
,;
</Typography.Paragraph>
{groupActivities.length === 0 ? (
<Typography.Text type="warning">
({(group?.platforms || []).map((p) => PLATFORM_LABEL[p]).join('/')}),
</Typography.Text>
) : (
<Checkbox.Group
value={activityIds}
onChange={(v) => setActivityIds(v as number[])}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{groupActivities.map((a) => (
<Checkbox key={a.id} value={a.id}>
<Tag color={PLATFORM_COLOR[a.platform]}>{PLATFORM_LABEL[a.platform]}</Tag>
{a.name}
{a.act_id ? <span style={{ color: '#999' }}> (actId={a.act_id})</span> : null}
</Checkbox>
))}
</Space>
</Checkbox.Group>
)}
{result && (
<div style={{ marginTop: 16 }}>
<Typography.Text strong>👇 ():</Typography.Text>
{result.results.map((item) => (
<div
key={item.code}
style={{
marginTop: 8,
padding: 10,
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 6,
}}
>
<div style={{ marginBottom: 4 }}>
<Tag color={PLATFORM_COLOR[item.platform]}>{PLATFORM_LABEL[item.platform]}</Tag>
<span style={{ fontSize: 13 }}>{item.activity_name}</span>
</div>
<Typography.Text copyable={{ text: item.redirect_url }} style={{ fontSize: 14, wordBreak: 'break-all' }}>
{item.redirect_url}
</Typography.Text>
</div>
))}
{result.results.some((i) => !i.redirect_url.startsWith('http')) && (
<Typography.Text type="warning" style={{ fontSize: 12 }}>
部分为相对路径:后端未配 CPS_REDIRECT_BASE,
</Typography.Text>
)}
</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 [editing, setEditing] = useState<CpsActivity | null>(null);
const delActivity = async (a: CpsActivity) => {
try {
await api.delete(`/admin/api/cps/activities/${a.id}`);
message.success('已删除活动');
reload();
} catch (e) {
message.error(errMsg(e));
}
};
const columns: ColumnsType<CpsActivity> = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{
title: '平台',
dataIndex: 'platform',
width: 80,
render: (v: string) => <Tag color={PLATFORM_COLOR[v]}>{PLATFORM_LABEL[v] || v}</Tag>,
},
{ title: '活动名', dataIndex: 'name', width: 180, ellipsis: true },
{
title: 'actId(美团)',
dataIndex: 'act_id',
width: 110,
render: (v: string | null) => v || '-',
},
{
title: '淘口令/链接',
dataIndex: 'payload',
width: 240,
ellipsis: true,
render: (v: string | null) => v || '-',
},
{
title: '落地页图',
dataIndex: 'image_url',
width: 80,
render: (v: string | null) =>
v ? (
<img src={v} alt="" style={{ width: 36, height: 46, objectFit: 'cover', borderRadius: 4 }} />
) : (
'-'
),
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s}</Tag>,
},
{ title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) },
...(canManage
? ([
{
title: '操作',
key: 'op',
fixed: 'right' as const,
width: 120,
render: (_: unknown, a: CpsActivity) => (
<Space>
<a onClick={() => setEditing(a)}></a>
<Popconfirm
title="删除这个活动?"
description="已生成的链接仍可用;淘宝活动删除后其落地页改用默认图。"
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => delActivity(a)}
>
<a style={{ color: '#cf1322' }}></a>
</Popconfirm>
</Space>
),
},
] as ColumnsType<CpsActivity>)
: []),
];
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: 1200 }}
/>
<ActivityFormModal open={createOpen} activity={null} onClose={() => setCreateOpen(false)} onDone={reload} />
<ActivityFormModal open={!!editing} activity={editing} onClose={() => setEditing(null)} onDone={reload} />
</div>
);
}
// 淘宝活动落地页图选择器:上传新图 或 点选已有图。受控组件(value = image_url 绝对 URL)。
function ImagePicker({ value, onChange }: { value?: string | null; onChange?: (v: string) => void }) {
const [existing, setExisting] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
useEffect(() => {
api
.get<{ images: string[] }>('/admin/api/cps/activity-images')
.then((r) => setExisting(r.data.images || []))
.catch(() => {});
}, []);
const upload = async (file: File) => {
setUploading(true);
try {
const fd = new FormData();
fd.append('file', file);
const r = await api.post<{ url: string }>('/admin/api/cps/upload-image', fd);
onChange?.(r.data.url);
setExisting((prev) => (prev.includes(r.data.url) ? prev : [r.data.url, ...prev]));
message.success('图片已上传');
} catch (e) {
message.error(errMsg(e, '上传失败'));
} finally {
setUploading(false);
}
};
const thumb = (u: string, w: number, selected: boolean) => (
<img
key={u}
src={u}
alt="落地页图"
onClick={() => onChange?.(u)}
style={{
width: w,
height: Math.round(w * 1.27),
objectFit: 'cover',
borderRadius: 6,
cursor: 'pointer',
border: selected ? '2px solid #ff4d4f' : '2px solid #f0f0f0',
}}
/>
);
return (
<div>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => {
upload(file);
return false; // 阻止 antd 默认上传,走我们自己的端点
}}
>
<Button loading={uploading}></Button>
</Upload>
{value && (
<div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: '#999' }}>:</span>
{thumb(value, 90, true)}
</div>
)}
{existing.length > 0 && (
<div style={{ marginTop: 10 }}>
<div style={{ fontSize: 12, color: '#999', marginBottom: 6 }}>:</div>
<Space wrap>{existing.map((u) => thumb(u, 56, u === value))}</Space>
</div>
)}
</div>
);
}
function ActivityFormModal({
open,
activity,
onClose,
onDone,
}: {
open: boolean;
activity: CpsActivity | null;
onClose: () => void;
onDone: () => void;
}) {
const [form] = Form.useForm();
const isEdit = !!activity;
const platform = (Form.useWatch('platform', form) as string | undefined) || 'meituan';
useEffect(() => {
if (open) {
if (activity) {
form.setFieldsValue({
name: activity.name,
platform: activity.platform,
act_id: activity.act_id,
product_view_sign: activity.product_view_sign,
payload: activity.payload,
image_url: activity.image_url,
remark: activity.remark,
status: activity.status,
});
} else {
form.resetFields();
form.setFieldsValue({ platform: 'meituan' });
}
}
}, [open, activity, form]);
const submit = async () => {
const v = await form.validateFields();
if (v.platform === 'meituan') {
if (!v.act_id && !v.product_view_sign) {
message.warning('美团活动:actId 与 productViewSign 至少填一个');
return;
}
} else if (!v.payload) {
message.warning(v.platform === 'taobao' ? '请填淘口令' : '请填京东链接');
return;
}
try {
if (isEdit && activity) {
await api.patch(`/admin/api/cps/activities/${activity.id}`, v);
message.success('已更新活动');
} else {
await api.post('/admin/api/cps/activities', 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" initialValues={{ platform: 'meituan' }}>
<Form.Item name="name" label="活动名" rules={[{ required: true }]}>
<Input placeholder="如:点我领取大额红包" />
</Form.Item>
<Form.Item name="platform" label="平台" rules={[{ required: true }]}>
<Select options={PLATFORM_OPTIONS} />
</Form.Item>
{platform === 'meituan' && (
<>
<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>
</>
)}
{platform === 'taobao' && (
<>
<Form.Item
name="payload"
label="淘口令(整段,运营从淘宝联盟复制,落地页原样复制给用户)"
rules={[{ required: true, message: '请填淘口令' }]}
>
<Input.TextArea rows={4} placeholder="如:8¥abcXYZ¥ https://m.tb.cn/h.xxx 长按复制本条..." maxLength={4096} />
</Form.Item>
<Form.Item
name="image_url"
label="落地页图(用户点链接后看到的页面主图,上传新图或选已有)"
rules={[{ required: true, message: '请上传或选择落地页图' }]}
>
<ImagePicker />
</Form.Item>
</>
)}
{platform === 'jd' && (
<Form.Item
name="payload"
label="京东推广链接(落地页 302 跳到它)"
rules={[{ required: true, message: '请填京东链接' }]}
>
<Input placeholder="如:https://u.jd.com/xxxxxx" maxLength={1024} />
</Form.Item>
)}
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={256} />
</Form.Item>
{isEdit && (
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'active', label: '启用(active)' },
{ value: 'archived', label: '归档(archived)' },
]}
/>
</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>
);
}