代下单功能

This commit is contained in:
2026-07-12 11:40:04 +08:00
parent d949301bc1
commit de01d36cdb
46 changed files with 2657 additions and 253 deletions
+9
View File
@@ -18,6 +18,10 @@ import CitiesPage from './pages/CitiesPage';
import CityPartnersPage from './pages/CityPartnersPage';
import CityWarehousesPage from './pages/CityWarehousesPage';
import StoreMediaPage from './pages/StoreMediaPage';
import PromoCodesPage from './pages/PromoCodesPage';
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
import PromoCodeUsersPage from './pages/promo/PromoCodeUsersPage';
import ProductsPage from './pages/ProductsPage';
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
import ResourcesPage from './pages/ResourcesPage';
@@ -52,6 +56,11 @@ export default function App() {
<Route path="/users" element={<UsersPage />} />
<Route path="/wechat-bindings" element={<WechatBindingsPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="/promo-codes" element={<PromoCodesPage />} />
<Route path="/promo-codes/:id" element={<PromoCodeDetailLayout />}>
<Route index element={<PromoCodeDetailPage />} />
<Route path="users" element={<PromoCodeUsersPage />} />
</Route>
<Route path="/products" element={<ProductsPage />} />
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
<Route path="/stores" element={<StoresPage />} />
+4 -1
View File
@@ -35,6 +35,7 @@ const MENU_ITEMS: MenuProps['items'] = [
],
},
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
{
key: 'stores-group',
icon: <ShopOutlined />,
@@ -118,7 +119,9 @@ export default function AdminLayout() {
navigate('/login');
}
const selectedKey = location.pathname;
const selectedKey = location.pathname.startsWith('/promo-codes')
? '/promo-codes'
: location.pathname;
return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
+3
View File
@@ -83,6 +83,9 @@ export type AdminUserRow = {
wechatVerified: boolean;
nickname: string | null;
status: number;
sourceType: string;
sourceRefId: string | null;
sourceLabel: string | null;
createdAt: string;
orderCount: number;
};
+236
View File
@@ -0,0 +1,236 @@
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
+69 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
Alert,
Button,
@@ -17,6 +17,7 @@ import {
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type Paginated } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
@@ -33,6 +34,7 @@ type UserDetail = AdminUserRow & {
wxUnionId?: string | null;
cityPref?: Record<string, unknown> | null;
mergedInto?: { id: string; userNo: string; phone: string | null; nickname: string | null } | null;
sourcePromo?: { id: string; code: string; name: string } | null;
orders?: UserOrderRow[];
mergedFromCount?: number;
addressCount?: number;
@@ -62,6 +64,7 @@ type BatchDeletePreview = {
export default function UsersPage() {
const navigate = useNavigate();
const location = useLocation();
const [form] = Form.useForm();
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
const [loading, setLoading] = useState(false);
@@ -105,6 +108,14 @@ export default function UsersPage() {
void load();
}, [load]);
useEffect(() => {
const openUserId = (location.state as { openUserId?: string } | null)?.openUserId;
if (openUserId) {
void openDetail(openUserId);
navigate(location.pathname, { replace: true, state: null });
}
}, [location.state, location.pathname, navigate]);
async function openDetail(id: string) {
const res = await request<UserDetail>(`/admin/users/${id}`);
setDetail(res);
@@ -225,6 +236,40 @@ export default function UsersPage() {
width: 100,
render: (v) => (v ? <Tag color="blue"></Tag> : <Tag></Tag>),
},
{
title: '来源类型',
dataIndex: 'sourceType',
width: 100,
render: (v: string) => (
<Tag color={v === 'PROMO_CODE' ? 'blue' : v === 'ORGANIC' ? 'default' : 'purple'}>
{USER_SOURCE_TYPE_LABELS[v as UserSourceType] || v}
</Tag>
),
},
{
title: '来源 ID',
dataIndex: 'sourceRefId',
width: 100,
ellipsis: true,
render: (v, row) => {
if (!v) return '—';
if (row.sourceType === 'PROMO_CODE') {
return (
<Link to={`/promo-codes/${v}`} onClick={(e) => e.stopPropagation()}>
{v}
</Link>
);
}
return v;
},
},
{
title: '来源标签',
dataIndex: 'sourceLabel',
width: 120,
ellipsis: true,
render: (v) => v || '—',
},
{
title: 'deviceKey',
dataIndex: 'deviceKey',
@@ -307,7 +352,7 @@ export default function UsersPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
scroll={{ x: 1500 }}
rowSelection={{
selectedRowKeys,
preserveSelectedRowKeys: true,
@@ -347,6 +392,28 @@ export default function UsersPage() {
<Descriptions.Item label="微信验证">
{detail.wechatVerified ? <Tag color="blue"></Tag> : <Tag></Tag>}
</Descriptions.Item>
<Descriptions.Item label="来源类型">
<Tag color={detail.sourceType === 'PROMO_CODE' ? 'blue' : 'default'}>
{USER_SOURCE_TYPE_LABELS[detail.sourceType as UserSourceType] || detail.sourceType}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="来源 ID">
{detail.sourceRefId ? (
detail.sourceType === 'PROMO_CODE' ? (
<Link to={`/promo-codes/${detail.sourceRefId}`}>{detail.sourceRefId}</Link>
) : (
detail.sourceRefId
)
) : '—'}
</Descriptions.Item>
<Descriptions.Item label="来源标签">{detail.sourceLabel || '—'}</Descriptions.Item>
{detail.sourcePromo && (
<Descriptions.Item label="推广码">
<Link to={`/promo-codes/${detail.sourcePromo.id}`}>
{detail.sourcePromo.name}{detail.sourcePromo.code}
</Link>
</Descriptions.Item>
)}
<Descriptions.Item label="wxOpenId">{detail.wxOpenId || '—'}</Descriptions.Item>
<Descriptions.Item label="wxUnionId">{detail.wxUnionId || '—'}</Descriptions.Item>
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react';
import { Link, Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
import { Breadcrumb, Button, Space, Spin, Tabs, Tag, Typography } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons';
import {
PROMO_CODE_STATUS_LABELS,
type PromoCodeItem,
type PromoCodeStats,
} from '@dukang/shared-types';
import { request } from '../../lib/api';
export type PromoCodeDetailContext = {
detail: PromoCodeItem & { stats?: PromoCodeStats };
reload: () => Promise<void>;
};
export default function PromoCodeDetailLayout() {
const { id = '' } = useParams();
const navigate = useNavigate();
const location = useLocation();
const [detail, setDetail] = useState<(PromoCodeItem & { stats?: PromoCodeStats }) | null>(null);
const [loading, setLoading] = useState(true);
async function loadDetail() {
setLoading(true);
try {
const res = await request<PromoCodeItem & { stats?: PromoCodeStats }>(`/admin/promo-codes/${id}`);
setDetail(res);
} catch {
setDetail(null);
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadDetail();
}, [id]);
const tabKey = location.pathname.endsWith('/users') ? 'users' : 'overview';
if (loading) {
return <Spin style={{ display: 'block', margin: '80px auto' }} />;
}
if (!detail) {
return (
<div>
<Typography.Text type="danger">广</Typography.Text>
<div style={{ marginTop: 16 }}>
<Button onClick={() => navigate('/promo-codes')}></Button>
</div>
</div>
);
}
return (
<div>
<Breadcrumb
style={{ marginBottom: 12 }}
items={[
{ title: <Link to="/promo-codes">广</Link> },
{ title: detail.name },
]}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
<Space direction="vertical" size={4}>
<Space align="center">
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/promo-codes')}
style={{ marginLeft: -8 }}
/>
<Typography.Title level={4} style={{ margin: 0 }}>
{detail.name}
</Typography.Title>
<Tag color={detail.status === 'ACTIVE' ? 'green' : 'default'}>
{PROMO_CODE_STATUS_LABELS[detail.status] || detail.status}
</Tag>
</Space>
<Typography.Text type="secondary" copyable={{ text: detail.code }}>
{detail.code}
</Typography.Text>
</Space>
</div>
<Tabs
activeKey={tabKey}
onChange={(key) => {
if (key === 'users') navigate(`/promo-codes/${id}/users`);
else navigate(`/promo-codes/${id}`);
}}
items={[
{ key: 'overview', label: '概览' },
{
key: 'users',
label: `关联用户${detail.stats?.sourceMarkedCount != null ? ` (${detail.stats.sourceMarkedCount})` : ''}`,
},
]}
style={{ marginBottom: 16 }}
/>
<Outlet context={{ detail, reload: loadDetail } satisfies PromoCodeDetailContext} />
</div>
);
}
@@ -0,0 +1,226 @@
import { useState } 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';
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]}>
<Col xs={24} lg={8}>
<Card title="推广二维码" size="small">
{detail.qrcodeUrl ? (
<div style={{ textAlign: 'center' }}>
<img
src={detail.qrcodeUrl}
alt="推广二维码"
style={{ width: 200, height: 200, marginBottom: 12 }}
/>
<Space direction="vertical" style={{ width: '100%' }}>
<Button
block
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-qrcode.png`)}
>
</Button>
</Space>
</div>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Card>
</Col>
<Col xs={24} lg={16}>
<Card
title="基础信息"
size="small"
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={{ xs: 1, sm: 2 }} bordered size="small">
<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" span={2}>
<Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="落地链接" span={2}>
<Typography.Text copyable={{ text: detail.landingUrl }}>{detail.landingUrl}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="OSS 地址" span={2}>
{detail.qrcodeUrl ? (
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis>
{detail.qrcodeUrl}
</Typography.Text>
) : '—'}
</Descriptions.Item>
<Descriptions.Item label="渠道负责人">
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{fmtTime(detail.updatedAt)}</Descriptions.Item>
</Descriptions>
</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?.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>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="来源标记用户" value={stats?.sourceMarkedCount ?? '—'} />
</Card>
</Col>
</Row>
<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>
</>
);
}
@@ -0,0 +1,89 @@
import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
import { Button, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
USER_SOURCE_TYPE_LABELS,
type PromoCodeAttributedUser,
type UserSourceType,
} from '@dukang/shared-types';
import { fmtTime } from '../../lib/constants';
import { useAdminList } from '../../lib/useAdminList';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
export default function PromoCodeUsersPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
const { detail } = useOutletContext<PromoCodeDetailContext>();
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<PromoCodeAttributedUser>(
`/admin/promo-codes/${id}/users`,
() => new URLSearchParams(),
[id],
);
const columns: ColumnsType<PromoCodeAttributedUser> = [
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
{
title: '验手机',
dataIndex: 'phoneVerifiedAt',
width: 90,
render: (v) => (v ? <Tag color="green"></Tag> : <Tag color="orange">访</Tag>),
},
{
title: '来源类型',
dataIndex: 'sourceType',
width: 100,
render: (v: string) => (
<Tag color={v === 'PROMO_CODE' ? 'blue' : 'default'}>
{USER_SOURCE_TYPE_LABELS[v as UserSourceType] || v}
</Tag>
),
},
{
title: '来源 ID',
dataIndex: 'sourceRefId',
width: 100,
render: (v) => (v === detail.id ? <Tag color="blue"></Tag> : v || '—'),
},
{
title: '首次触达',
dataIndex: 'firstTouchAt',
width: 160,
render: (v) => (v ? fmtTime(v) : '—'),
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{ title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 100,
fixed: 'right',
render: (_, row) => (
<Space size="small">
<Button type="link" size="small" onClick={() => navigate('/users', { state: { openUserId: row.id } })}>
</Button>
</Space>
),
},
];
return (
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
);
}