237 lines
8.1 KiB
TypeScript
237 lines
8.1 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
import {
|
|
|
|
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
|
|
|
|
} from 'antd';
|
|
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
|
|
import {
|
|
|
|
PROMO_CODE_SCENE_LABELS,
|
|
|
|
PROMO_CODE_STATUS_LABELS,
|
|
|
|
promoConversion,
|
|
|
|
type PromoCodeItem,
|
|
|
|
type PromoCodeScene,
|
|
|
|
} from '@dukang/shared-types';
|
|
|
|
import { request } from '../lib/api';
|
|
|
|
import { fmtTime } from '../lib/constants';
|
|
|
|
import { useAdminList } from '../lib/useAdminList';
|
|
|
|
|
|
|
|
type Row = PromoCodeItem;
|
|
|
|
|
|
|
|
type SceneOption = { value: PromoCodeScene; label: string };
|
|
|
|
|
|
|
|
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 PromoCodesPage() {
|
|
|
|
const navigate = useNavigate();
|
|
|
|
const [filterForm] = Form.useForm();
|
|
|
|
const [createForm] = Form.useForm();
|
|
|
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
|
|
|
const [scenes, setScenes] = useState<SceneOption[]>(
|
|
|
|
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
|
|
|
|
value: value as PromoCodeScene,
|
|
|
|
label,
|
|
|
|
})),
|
|
|
|
);
|
|
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
|
|
|
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
|
|
|
'/admin/promo-codes',
|
|
|
|
() => {
|
|
|
|
const qs = new URLSearchParams();
|
|
|
|
if (filters.name) qs.set('name', filters.name);
|
|
|
|
if (filters.code) qs.set('code', filters.code);
|
|
|
|
if (filters.status) qs.set('status', filters.status);
|
|
|
|
if (filters.scene) qs.set('scene', filters.scene);
|
|
|
|
return qs;
|
|
|
|
},
|
|
|
|
[filters],
|
|
|
|
);
|
|
|
|
|
|
|
|
async function loadScenes() {
|
|
|
|
try {
|
|
|
|
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
|
|
|
|
if (list.length) setScenes(list);
|
|
|
|
} catch {
|
|
|
|
/* 使用本地默认场景 */
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
void loadScenes();
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
const columns: ColumnsType<Row> = [
|
|
|
|
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
|
|
|
|
{ title: '码值', dataIndex: 'code', width: 110 },
|
|
|
|
{
|
|
|
|
title: '场景',
|
|
|
|
dataIndex: 'scene',
|
|
|
|
width: 110,
|
|
|
|
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
title: '状态',
|
|
|
|
dataIndex: 'status',
|
|
|
|
width: 90,
|
|
|
|
render: (s) => (
|
|
|
|
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
|
|
|
|
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
|
|
|
|
</Tag>
|
|
|
|
),
|
|
|
|
},
|
|
|
|
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
|
|
|
|
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
|
|
|
{
|
|
|
|
title: '转化率',
|
|
|
|
width: 90,
|
|
|
|
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
title: '渠道负责人',
|
|
|
|
width: 120,
|
|
|
|
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
|
|
|
|
},
|
|
|
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
|
|
|
{
|
|
|
|
title: '操作',
|
|
|
|
width: 220,
|
|
|
|
fixed: 'right',
|
|
|
|
render: (_, row) => (
|
|
|
|
<Space size="small" wrap>
|
|
|
|
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
|
|
|
|
详情
|
|
|
|
</Button>
|
|
|
|
<Button
|
|
|
|
type="link"
|
|
|
|
size="small"
|
|
|
|
disabled={!row.qrcodeUrl}
|
|
|
|
onClick={() => {
|
|
|
|
if (!row.qrcodeUrl) return;
|
|
|
|
void downloadQrcode(row.qrcodeUrl, `${row.code}-qrcode.png`);
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
下载二维码
|
|
|
|
</Button>
|
|
|
|
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
|
|
|
|
关联用户
|
|
|
|
</Button>
|
|
|
|
</Space>
|
|
|
|
),
|
|
|
|
},
|
|
|
|
];
|
|
|
|
|
|
|
|
async function handleCreate(values: Record<string, string>) {
|
|
|
|
setCreating(true);
|
|
|
|
try {
|
|
|
|
const created = await request<PromoCodeItem>('/admin/promo-codes', {
|
|
|
|
method: 'POST',
|
|
|
|
body: JSON.stringify({
|
|
|
|
name: values.name,
|
|
|
|
code: values.code?.trim() || undefined,
|
|
|
|
scene: values.scene,
|
|
|
|
ownerUserId: values.ownerUserId?.trim() || undefined,
|
|
|
|
remark: values.remark?.trim() || undefined,
|
|
|
|
}),
|
|
|
|
});
|
|
|
|
message.success('推广码已创建');
|
|
|
|
setCreateOpen(false);
|
|
|
|
createForm.resetFields();
|
|
|
|
void reload();
|
|
|
|
navigate(`/promo-codes/${created.id}`);
|
|
|
|
} catch (e) {
|
|
|
|
message.error(e instanceof Error ? e.message : '创建失败');
|
|
|
|
} finally {
|
|
|
|
setCreating(false);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div>
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
|
|
|
<Typography.Title level={4} style={{ margin: 0 }}>推广码管理</Typography.Title>
|
|
|
|
<Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<Form
|
|
|
|
form={filterForm}
|
|
|
|
layout="inline"
|
|
|
|
style={{ marginBottom: 16 }}
|
|
|
|
onFinish={(v) => { setFilters(v); setPage(1); }}
|
|
|
|
>
|
|
|
|
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
|
|
|
<Form.Item name="code" label="码值"><Input allowClear /></Form.Item>
|
|
|
|
<Form.Item name="scene" label="场景">
|
|
|
|
<Select allowClear style={{ width: 130 }} options={scenes} />
|
|
|
|
</Form.Item>
|
|
|
|
<Form.Item name="status" label="状态">
|
|
|
|
<Select
|
|
|
|
allowClear
|
|
|
|
style={{ width: 100 }}
|
|
|
|
options={Object.entries(PROMO_CODE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
|
|
|
/>
|
|
|
|
</Form.Item>
|
|
|
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
|
|
|
</Form>
|
|
|
|
|
|
|
|
<Table
|
|
|
|
rowKey="id"
|
|
|
|
className="admin-table-nowrap"
|
|
|
|
loading={loading}
|
|
|
|
columns={columns}
|
|
|
|
dataSource={data?.items ?? []}
|
|
|
|
scroll={{ x: 1200 }}
|
|
|
|
pagination={{
|
|
|
|
current: page,
|
|
|
|
pageSize,
|
|
|
|
total: data?.total ?? 0,
|
|
|
|
showSizeChanger: true,
|
|
|
|
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
|
|
|
|
<Modal
|
|
|
|
title="创建推广码"
|
|
|
|
open={createOpen}
|
|
|
|
onCancel={() => setCreateOpen(false)}
|
|
|
|
footer={null}
|
|
|
|
destroyOnClose
|
|
|
|
>
|
|
|
|
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ scene: 'ONLINE_LINK' }}>
|
|
|
|
<Form.Item name="name" label="推广码名称" rules={[{ required: true, message: '请填写名称' }]}>
|
|
|
|
<Input placeholder="如:郑州品鉴会、门店地推" />
|
|
|
|
</Form.Item>
|
|
|
|
<Form.Item name="scene" label="场景" rules={[{ required: true }]}>
|
|
|
|
<Select options={scenes} />
|
|
|
|
</Form.Item>
|
|
|
|
<Form.Item name="code" label="自定义码值(选填)">
|
|
|
|
<Input placeholder="留空自动生成,如 DKDEMO1" />
|
|
|
|
</Form.Item>
|
|
|
|
<Form.Item name="ownerUserId" label="关联用户 ID(选填)">
|
|
|
|
<Input placeholder="渠道负责人,填写用户数据库 ID" />
|
|
|
|
</Form.Item>
|
|
|
|
<Form.Item name="remark" label="备注">
|
|
|
|
<Input.TextArea rows={2} placeholder="渠道说明、活动备注等" />
|
|
|
|
</Form.Item>
|
|
|
|
<Button type="primary" htmlType="submit" loading={creating} block>
|
|
|
|
创建并生成二维码
|
|
|
|
</Button>
|
|
|
|
</Form>
|
|
|
|
</Modal>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|