1a6e590a42
- 对账统计页群名可点进群详情页 /cps/groups/[id] - @ant-design/plots 折线图,4 指标多线,Segmented 切时间范围,y 轴 0 基线 - 数据稀疏靠后端补零保证折线连续/x 轴铺满 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
964 lines
30 KiB
TypeScript
964 lines
30 KiB
TypeScript
'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>
|
||
);
|
||
}
|