代下单功能
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import BillsPage from './pages/BillsPage';
|
||||
import SettlementPage from './pages/SettlementPage';
|
||||
import ReshipPage from './pages/ReshipPage';
|
||||
import WeeklyReportPage from './pages/WeeklyReportPage';
|
||||
import LeaderboardPage from './pages/LeaderboardPage';
|
||||
import ProxyOrderPage from './pages/ProxyOrderPage';
|
||||
import StaffListPage from './pages/StaffListPage';
|
||||
import StaffCreatePage from './pages/StaffCreatePage';
|
||||
|
||||
@@ -32,6 +32,7 @@ function PrimaryRoutes() {
|
||||
<Route path="/center/settlement" element={<SettlementPage />} />
|
||||
<Route path="/center/staff" element={<StaffListPage />} />
|
||||
<Route path="/center/staff/new" element={<StaffCreatePage />} />
|
||||
<Route path="/proxy-order" element={<ProxyOrderPage />} />
|
||||
<Route path="/reshipments" element={<ReshipPage />} />
|
||||
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
|
||||
@@ -100,6 +100,12 @@ export default function HomePage() {
|
||||
|
||||
<section className="partner-bento-card">
|
||||
<div className="partner-quick-actions">
|
||||
<Link to="/proxy-order" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--red">
|
||||
<span className="material-symbols-outlined">shopping_cart_checkout</span>
|
||||
</div>
|
||||
<span className="partner-quick-action-label">代下单</span>
|
||||
</Link>
|
||||
<Link to="/stores/new" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--red">
|
||||
<span className="material-symbols-outlined">add_business</span>
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
||||
import { request } from '../lib/api';
|
||||
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import type {
|
||||
PartnerProxyOrderCreateRequest,
|
||||
PartnerProxyOrderOptions,
|
||||
PartnerProxyOrderPreviewResult,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function ProxyOrderPage() {
|
||||
const navigate = useNavigate();
|
||||
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
|
||||
const [loadingOptions, setLoadingOptions] = useState(true);
|
||||
const [phone, setPhone] = useState('');
|
||||
const [smsCode, setSmsCode] = useState('');
|
||||
const [receiverName, setReceiverName] = useState('');
|
||||
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
||||
const [addressDetail, setAddressDetail] = useState('');
|
||||
const [productId, setProductId] = useState('');
|
||||
const [quantity, setQuantity] = useState(2);
|
||||
const [promoCodeId, setPromoCodeId] = useState('');
|
||||
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [smsCooldown, setSmsCooldown] = useState(0);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
|
||||
const regionLabel = region ? formatRegionLabel(region) : '';
|
||||
|
||||
useEffect(() => {
|
||||
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
|
||||
.then((data) => {
|
||||
setOptions(data);
|
||||
if (data.products[0]) setProductId(data.products[0].id);
|
||||
})
|
||||
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoadingOptions(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId || quantity < 1) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setPreviewLoading(true);
|
||||
request<PartnerProxyOrderPreviewResult>('PARTNER_H5', '/partner/proxy-orders/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
productId,
|
||||
quantity,
|
||||
receiverCity: region.city || undefined,
|
||||
receiverDistrict: region.district || undefined,
|
||||
}),
|
||||
silent: true,
|
||||
})
|
||||
.then(setPreview)
|
||||
.catch(() => setPreview(null))
|
||||
.finally(() => setPreviewLoading(false));
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [productId, quantity, region.city, region.district]);
|
||||
|
||||
async function sendSms() {
|
||||
setMsg('');
|
||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||
setMsg('请输入有效手机号');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await request<{ maskedPhone: string }>('PARTNER_H5', '/partner/proxy-orders/send-sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: phone.trim() }),
|
||||
silent: true,
|
||||
});
|
||||
setMsg(`验证码已发送至 ${res.maskedPhone}`);
|
||||
setSmsCooldown(60);
|
||||
const timer = setInterval(() => {
|
||||
setSmsCooldown((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '发送失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
setMsg('');
|
||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||
setMsg('请输入有效手机号');
|
||||
return;
|
||||
}
|
||||
if (!smsCode.trim()) {
|
||||
setMsg('请输入验证码');
|
||||
return;
|
||||
}
|
||||
if (!region?.province || !region.city || !region.district) {
|
||||
setMsg('请选择省市区');
|
||||
return;
|
||||
}
|
||||
if (!addressDetail.trim()) {
|
||||
setMsg('请填写详细地址');
|
||||
return;
|
||||
}
|
||||
if (!productId) {
|
||||
setMsg('请选择商品');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: PartnerProxyOrderCreateRequest = {
|
||||
phone: phone.trim(),
|
||||
smsCode: smsCode.trim(),
|
||||
receiverName: receiverName.trim() || undefined,
|
||||
province: region.province,
|
||||
city: region.city,
|
||||
district: region.district,
|
||||
addressDetail: addressDetail.trim(),
|
||||
productId,
|
||||
quantity,
|
||||
promoCodeId: promoCodeId || undefined,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const order = await request<{ id: string; orderNo: string }>('PARTNER_H5', '/partner/proxy-orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
silent: true,
|
||||
});
|
||||
toastSuccess(`代下单成功:${order.orderNo}`);
|
||||
navigate(`/orders/${order.id}`);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||
|
||||
return (
|
||||
<div className="page partner-proxy-order-page">
|
||||
<PageHeader title="代下单" onBack={() => navigate(-1)} />
|
||||
|
||||
<main className="partner-form-card" style={{ margin: '0 16px 24px' }}>
|
||||
{loadingOptions ? (
|
||||
<p className="label-md text-muted">加载商品与推广码…</p>
|
||||
) : (
|
||||
<>
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">用户手机号</label>
|
||||
<div className="partner-input-row">
|
||||
<div className="partner-input-wrap" style={{ flex: 1 }}>
|
||||
<input
|
||||
className="partner-input"
|
||||
placeholder="11 位手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={smsCooldown > 0}
|
||||
onClick={() => void sendSms()}
|
||||
>
|
||||
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">短信验证码</label>
|
||||
<div className="partner-input-wrap">
|
||||
<input
|
||||
className="partner-input"
|
||||
placeholder="线下代发货确认码"
|
||||
value={smsCode}
|
||||
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">收货人(选填)</label>
|
||||
<div className="partner-input-wrap">
|
||||
<input
|
||||
className="partner-input"
|
||||
placeholder="默认:用户+手机尾号"
|
||||
value={receiverName}
|
||||
onChange={(e) => setReceiverName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">收货地区</label>
|
||||
<ChinaRegionPicker value={regionCodes} onChange={setRegionCodes} />
|
||||
{regionLabel && <p className="label-md text-muted" style={{ marginTop: 8 }}>{regionLabel}</p>}
|
||||
</section>
|
||||
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">详细地址</label>
|
||||
<div className="partner-field-input partner-field-input--block">
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="街道、门牌号等"
|
||||
value={addressDetail}
|
||||
onChange={(e) => setAddressDetail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">商品</label>
|
||||
<select
|
||||
className="partner-input"
|
||||
value={productId}
|
||||
onChange={(e) => setProductId(e.target.value)}
|
||||
style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{(options?.products ?? []).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}({p.spec})¥{fmtMoney(p.price)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">数量</label>
|
||||
<div className="partner-input-wrap">
|
||||
<input
|
||||
className="partner-input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(options?.promoCodes.length ?? 0) > 0 && (
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">绑定推广码(选填)</label>
|
||||
<select
|
||||
className="partner-input"
|
||||
value={promoCodeId}
|
||||
onChange={(e) => setPromoCodeId(e.target.value)}
|
||||
style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<option value="">不绑定</option>
|
||||
{options!.promoCodes.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}({p.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="partner-proxy-fee-card">
|
||||
<h3 className="headline-md">费用明细</h3>
|
||||
{previewLoading ? (
|
||||
<p className="label-md text-muted">计算中…</p>
|
||||
) : preview ? (
|
||||
<>
|
||||
<div className="partner-proxy-fee-row">
|
||||
<span className="label-md text-muted">商品单价</span>
|
||||
<span className="body-md">¥{fmtMoney(preview.unitPrice)}</span>
|
||||
</div>
|
||||
<div className="partner-proxy-fee-row">
|
||||
<span className="label-md text-muted">数量</span>
|
||||
<span className="body-md">×{quantity}</span>
|
||||
</div>
|
||||
<div className="partner-proxy-fee-row">
|
||||
<span className="label-md text-muted">配送类型</span>
|
||||
<span className="body-md">{preview.deliveryType === 'LOCAL' ? '同城' : '跨城'}</span>
|
||||
</div>
|
||||
<div className="partner-proxy-fee-row">
|
||||
<span className="label-md text-muted">权益额</span>
|
||||
<span className="body-md">¥{fmtMoney(preview.benefitAmount)}</span>
|
||||
</div>
|
||||
<div className="partner-proxy-fee-row partner-proxy-fee-row--total">
|
||||
<span className="headline-md">应付金额</span>
|
||||
<span className="amount-lg text-primary">¥{fmtMoney(preview.payAmount)}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="label-md text-muted">
|
||||
{selectedProduct ? '请确认数量与地址后查看费用' : '请选择商品'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{msg && <p className="partner-form-error" role="alert">{msg}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-block"
|
||||
disabled={submitting || !preview}
|
||||
onClick={() => void submit()}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
{submitting ? '提交中…' : '验证码确认并代下单'}
|
||||
</button>
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 12, lineHeight: 1.5 }}>
|
||||
提交后将自动创建/关联用户,订单类型为「线下代下单」,状态直接标记为已收货,并发放对应权益。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3031,3 +3031,28 @@ body {
|
||||
color: var(--color-outline, #8d706e);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.partner-form-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.partner-proxy-fee-card {
|
||||
margin-top: 8px;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(166, 29, 36, 0.04);
|
||||
border: 1px solid rgba(166, 29, 36, 0.08);
|
||||
}
|
||||
|
||||
.partner-proxy-fee-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.partner-proxy-fee-row--total {
|
||||
margin-top: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed rgba(166, 29, 36, 0.15);
|
||||
}
|
||||
|
||||
@@ -20,13 +20,12 @@ import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||
import PayPage from './pages/PayPage';
|
||||
import CustomerServicePage from './pages/CustomerServicePage';
|
||||
import { UserSessionProvider } from './contexts/UserSessionContext';
|
||||
import { capturePromoFromUrl, touchPromoIfNeeded } from './lib/promo';
|
||||
import { capturePromoFromUrl } from './lib/promo';
|
||||
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
||||
|
||||
function PromoBootstrap() {
|
||||
useEffect(() => {
|
||||
capturePromoFromUrl();
|
||||
void touchPromoIfNeeded();
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
import { apiBase } from './api';
|
||||
|
||||
export const PROMO_STORAGE_KEY = 'dukang_promo_code';
|
||||
export const PROMO_PID_STORAGE_KEY = 'dukang_promo_pid';
|
||||
|
||||
function readPromoFromSearch(search: string): string | null {
|
||||
function readPromoFromSearch(search: string): { code: string | null; pid: string | null } {
|
||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
||||
const code = params.get('promo')?.trim();
|
||||
return code ? code.toUpperCase() : null;
|
||||
const pid = params.get('pid')?.trim();
|
||||
return {
|
||||
code: code ? code.toUpperCase() : null,
|
||||
pid: pid || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 解析 URL 中的 ?promo= 并写入 sessionStorage */
|
||||
/** 解析 URL 中的 ?promo= / ?pid= 并写入 sessionStorage */
|
||||
export function capturePromoFromUrl(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
let code = readPromoFromSearch(window.location.search);
|
||||
if (!code && window.location.hash.includes('?')) {
|
||||
let parsed = readPromoFromSearch(window.location.search);
|
||||
if (!parsed.code && !parsed.pid && window.location.hash.includes('?')) {
|
||||
const hashQuery = window.location.hash.slice(window.location.hash.indexOf('?'));
|
||||
code = readPromoFromSearch(hashQuery);
|
||||
parsed = readPromoFromSearch(hashQuery);
|
||||
}
|
||||
if (code) {
|
||||
sessionStorage.setItem(PROMO_STORAGE_KEY, code);
|
||||
if (parsed.code) {
|
||||
sessionStorage.setItem(PROMO_STORAGE_KEY, parsed.code);
|
||||
}
|
||||
return code ?? sessionStorage.getItem(PROMO_STORAGE_KEY);
|
||||
if (parsed.pid) {
|
||||
sessionStorage.setItem(PROMO_PID_STORAGE_KEY, parsed.pid);
|
||||
}
|
||||
return parsed.code ?? sessionStorage.getItem(PROMO_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getStoredPromoCode(): string | null {
|
||||
@@ -27,10 +35,16 @@ export function getStoredPromoCode(): string | null {
|
||||
return sessionStorage.getItem(PROMO_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getStoredPromoPid(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return sessionStorage.getItem(PROMO_PID_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/** 调用 /promo/touch 完成扫码归因(OptionalJwt:未登录也累加 scan_count) */
|
||||
export async function touchPromoIfNeeded(): Promise<void> {
|
||||
const promoCode = getStoredPromoCode();
|
||||
if (!promoCode) return;
|
||||
const qrcodeId = getStoredPromoPid();
|
||||
if (!promoCode && !qrcodeId) return;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -43,7 +57,10 @@ export async function touchPromoIfNeeded(): Promise<void> {
|
||||
const res = await fetch(`${apiBase}/promo/touch`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ promoCode }),
|
||||
body: JSON.stringify({
|
||||
...(promoCode ? { promoCode } : {}),
|
||||
...(qrcodeId ? { qrcodeId } : {}),
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) return;
|
||||
|
||||
Reference in New Issue
Block a user