Files
dukang/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx
T

390 lines
14 KiB
TypeScript

import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import { QuestionCircleOutlined } from '@ant-design/icons';
import {
Button,
Card,
Col,
Descriptions,
Form,
Input,
Modal,
Popconfirm,
Row,
Select,
Space,
Statistic,
Tooltip,
Typography,
message,
} from 'antd';
import {
PROMO_CODE_SCENE_LABELS,
PROMO_CODE_STATUS_LABELS,
promoConversion,
} from '@dukang/shared-types';
import { request, type Paginated } from '../../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../../lib/constants';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
import PromoCodeMetricsPanel from './PromoCodeMetricsPanel';
const descLabelStyle: CSSProperties = {
whiteSpace: 'nowrap',
width: 108,
};
const descContentStyle: CSSProperties = {
wordBreak: 'break-all',
};
const SCAN_HINT =
'按人次累计:每次扫码或带参进入记一次,未登录也计入;同一用户多次进入记多次。同一次打开内的重复上报、以及登录后补归因不重复计。不是按人去重。';
const ATTRIBUTION_HINT =
'首次触达本推广码的用户,按人计:每人只归因一次、只归一个码。已登录扫码时,若还没有归因记录则记到本码;未登录先扫码、登录后再补记。之后再扫其他码不会改。未登录连续扫多个码时,登录后归到最后一次缓存的码。与「扫码注册」不同:已有分享等其他来源的用户仍可计入归因,但不计入扫码注册。';
const REGISTER_HINT =
'用户来源仍是自然量时,才标记为本推广码。已有分享等其他来源的用户不计入,因此通常小于或等于归因用户数。';
const ORDER_HINT = '下单时绑定当时的归因推广码,仅统计已完成订单。';
const CONVERSION_HINT =
'已完成订单数 ÷ 扫码进入次数(人次)。同一人多次扫码会放大分母,未登录扫码也计入。';
type PartnerOption = { id: string; companyName?: string | null; name?: string | null; phone?: string | null };
function formatPartnerLabel(p?: { companyName?: string | null; name?: string | null; phone?: string | null; id: string } | null) {
if (!p) return '—';
const title = p.companyName || p.name || p.id;
return p.phone ? `${title} · ${p.phone}` : title;
}
function formatPartnerNames(partners?: Array<{ companyName?: string | null; name?: string | null; id: string }> | null) {
if (!partners?.length) return '—';
return partners.map((p) => p.companyName || p.name || p.id).join('、');
}
function StatHint({ label, hint }: { label: string; hint: string }) {
return (
<span>
{label}
<Tooltip title={hint} overlayInnerStyle={{ maxWidth: 360 }}>
<QuestionCircleOutlined
style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }}
onClick={(e) => e.stopPropagation()}
/>
</Tooltip>
</span>
);
}
async function downloadQrcode(url: string, filename: string) {
try {
const res = await fetch(url);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(objectUrl);
} catch {
window.open(url, '_blank');
}
}
export default function PromoCodeDetailPage() {
const navigate = useNavigate();
const { detail, reload } = useOutletContext<PromoCodeDetailContext>();
const [editForm] = Form.useForm();
const [editOpen, setEditOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const stats = detail.stats;
const partnerSelectOptions = useMemo(() => {
const map = new Map(partners.map((p) => [p.id, p]));
for (const p of detail.channelOwners ?? []) map.set(p.id, p);
if (detail.assocPartner) map.set(detail.assocPartner.id, detail.assocPartner);
return [...map.values()].map((p) => ({ value: p.id, label: formatPartnerLabel(p) }));
}, [partners, detail.channelOwners, detail.assocPartner]);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}, []);
async function handleEdit(values: {
name: string;
scene: string;
remark?: string;
channelOwnerPartnerIds?: string[];
assocPartnerAccountId?: string;
}) {
setSaving(true);
try {
await request(`/admin/promo-codes/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
name: values.name,
scene: values.scene,
channelOwnerPartnerIds: values.channelOwnerPartnerIds ?? [],
assocPartnerAccountId: values.assocPartnerAccountId || null,
remark: values.remark?.trim() || null,
}),
});
message.success('已保存');
setEditOpen(false);
await reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
return (
<>
<Row gutter={[16, 16]} align="top">
<Col flex="1 1 480px" style={{ minWidth: 0 }}>
<Card
title="基础信息"
size="small"
styles={{ body: { paddingTop: 12 } }}
extra={(
<Space>
<Button
size="small"
onClick={() => {
editForm.setFieldsValue({
name: detail.name,
scene: detail.scene,
remark: detail.remark,
channelOwnerPartnerIds: (detail.channelOwners ?? []).map((p) => p.id),
assocPartnerAccountId: detail.assocPartner?.id,
});
setEditOpen(true);
}}
>
编辑
</Button>
<Popconfirm
title={detail.status === 'ACTIVE' ? '确认关闭此推广码?关闭后不可再归因' : '确认重新启用?'}
onConfirm={async () => {
await request(`/admin/promo-codes/${detail.id}/status`, {
method: 'PUT',
body: JSON.stringify({
status: detail.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE',
}),
});
message.success('状态已更新');
await reload();
}}
>
<Button size="small" danger={detail.status === 'ACTIVE'}>
{detail.status === 'ACTIVE' ? '关闭' : '启用'}
</Button>
</Popconfirm>
</Space>
)}
>
<Descriptions
column={2}
bordered
size="small"
layout="horizontal"
labelStyle={descLabelStyle}
contentStyle={descContentStyle}
styles={{
label: descLabelStyle,
content: descContentStyle,
}}
>
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
<Descriptions.Item label="码值">{detail.code}</Descriptions.Item>
<Descriptions.Item label="场景">
{PROMO_CODE_SCENE_LABELS[detail.scene] || detail.scene}
</Descriptions.Item>
<Descriptions.Item label="状态">
{PROMO_CODE_STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item>
<Descriptions.Item label="活动 ID">
<Typography.Text copyable={{ text: String(detail.id) }} style={{ whiteSpace: 'nowrap' }}>
{detail.id}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="二维码 ID">
<Typography.Text copyable={{ text: detail.qrcodeId }} style={{ whiteSpace: 'nowrap' }}>
{detail.qrcodeId}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="小程序码 OSS" span={2}>
{detail.qrcodeUrl ? (
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis style={{ maxWidth: '100%' }}>
{detail.qrcodeUrl}
</Typography.Text>
) : '—'}
</Descriptions.Item>
<Descriptions.Item label="渠道负责人">
<span style={{ whiteSpace: 'nowrap' }}>
{formatPartnerNames(detail.channelOwners)}
</span>
</Descriptions.Item>
<Descriptions.Item label="关联合伙人">
<span style={{ whiteSpace: 'nowrap' }}>
{formatPartnerLabel(detail.assocPartner)}
</span>
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
<Descriptions.Item label="创建时间">
<span style={{ whiteSpace: 'nowrap' }}>{fmtTime(detail.createdAt)}</span>
</Descriptions.Item>
<Descriptions.Item label="更新时间">
<span style={{ whiteSpace: 'nowrap' }}>{fmtTime(detail.updatedAt)}</span>
</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<Col flex="0 0 220px">
<Card title="小程序码" size="small" styles={{ body: { textAlign: 'center', padding: 12 } }}>
{detail.qrcodeUrl ? (
<>
<img
src={detail.qrcodeUrl}
alt="推广小程序码"
style={{ width: 168, height: 168, display: 'block', margin: '0 auto 8px' }}
/>
<Typography.Paragraph
type="secondary"
style={{ marginBottom: 8, fontSize: 12, whiteSpace: 'nowrap' }}
>
scene={detail.id}
</Typography.Paragraph>
<Button
block
size="small"
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-wxacode.png`)}
>
下载小程序码
</Button>
</>
) : (
<Typography.Text type="secondary">暂无小程序码</Typography.Text>
)}
</Card>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 16 }}>
<Col xs={12} sm={6}>
<Card
size="small"
hoverable
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/promo-codes/${detail.id}?eventType=SCAN`)}
>
<Statistic
title={<StatHint label="扫码进入次数" hint={SCAN_HINT} />}
value={stats?.scanCount ?? detail.scanCount}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title={<StatHint label="归因用户数" hint={ATTRIBUTION_HINT} />}
value={stats?.attributionCount ?? 0}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title={<StatHint label="扫码注册用户数" hint={REGISTER_HINT} />}
value={stats?.registerCount ?? stats?.sourceMarkedCount ?? 0}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card
size="small"
hoverable
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/orders?promoCodeId=${detail.id}&status=COMPLETED`)}
>
<Statistic
title={<StatHint label="订单数" hint={ORDER_HINT} />}
value={stats?.orderCount ?? detail.orderCount}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title={<StatHint label="转化率" hint={CONVERSION_HINT} />}
value={promoConversion(stats?.scanCount ?? detail.scanCount, stats?.orderCount ?? detail.orderCount)}
/>
</Card>
</Col>
</Row>
<PromoCodeMetricsPanel promoId={detail.id} />
<Modal
title="编辑推广码"
open={editOpen}
onCancel={() => setEditOpen(false)}
footer={null}
destroyOnClose
>
<Form form={editForm} layout="vertical" onFinish={handleEdit}>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="scene" label="场景" rules={[{ required: true }]}>
<Select
options={Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item
name="channelOwnerPartnerIds"
label="渠道负责人"
extra="可空、可多选。被指定的主合伙人可在 H5 查看扫码/归因/订单数量。"
>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="选择主合伙人"
options={partnerSelectOptions}
/>
</Form.Item>
<Form.Item
name="assocPartnerAccountId"
label="关联合伙人"
extra="可空。扫该码且尚未关联的用户将绑定该合伙人;已关联他人不换绑,不回刷历史。"
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="选择主合伙人"
options={partnerSelectOptions}
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={saving} block>
保存
</Button>
</Form>
</Modal>
</>
);
}