53e0db6a98
Add log_promo_event for scan/attribution/register/order with IP; admin metrics APIs and ECharts on promo detail page. Co-authored-by: Cursor <cursoragent@cursor.com>
279 lines
9.3 KiB
TypeScript
279 lines
9.3 KiB
TypeScript
import { useState, type CSSProperties } from 'react';
|
|
import { useOutletContext } from 'react-router-dom';
|
|
import {
|
|
Button,
|
|
Card,
|
|
Col,
|
|
Descriptions,
|
|
Form,
|
|
Input,
|
|
Modal,
|
|
Popconfirm,
|
|
Row,
|
|
Select,
|
|
Space,
|
|
Statistic,
|
|
Typography,
|
|
message,
|
|
} from 'antd';
|
|
import {
|
|
PROMO_CODE_SCENE_LABELS,
|
|
PROMO_CODE_STATUS_LABELS,
|
|
promoConversion,
|
|
} from '@dukang/shared-types';
|
|
import { request } from '../../lib/api';
|
|
import { 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',
|
|
};
|
|
|
|
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 { detail, reload } = useOutletContext<PromoCodeDetailContext>();
|
|
const [editForm] = Form.useForm();
|
|
const [editOpen, setEditOpen] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const stats = detail.stats;
|
|
|
|
async function handleEdit(values: Record<string, string>) {
|
|
setSaving(true);
|
|
try {
|
|
await request(`/admin/promo-codes/${detail.id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
name: values.name,
|
|
scene: values.scene,
|
|
ownerUserId: values.ownerUserId?.trim() || 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,
|
|
ownerUserId: detail.ownerUser?.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' }}>
|
|
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
|
|
</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">
|
|
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="归因用户数"
|
|
value={stats?.attributionCount ?? 0}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="扫码注册用户数"
|
|
value={stats?.registerCount ?? stats?.sourceMarkedCount ?? 0}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic title="订单数" value={stats?.orderCount ?? detail.orderCount} />
|
|
</Card>
|
|
</Col>
|
|
<Col xs={12} sm={6}>
|
|
<Card size="small">
|
|
<Statistic
|
|
title="转化率"
|
|
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="ownerUserId" label="关联用户 ID">
|
|
<Input placeholder="留空表示解除关联" />
|
|
</Form.Item>
|
|
<Form.Item name="remark" label="备注">
|
|
<Input.TextArea rows={2} />
|
|
</Form.Item>
|
|
<Button type="primary" htmlType="submit" loading={saving} block>
|
|
保存
|
|
</Button>
|
|
</Form>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|