Compare commits
3 Commits
047ffd7b16
...
cebeefdb22
| Author | SHA1 | Date | |
|---|---|---|---|
| cebeefdb22 | |||
| e93e4a7721 | |||
| cd6b82abd8 |
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
||||
Table, Tabs, Tag, Typography, message,
|
||||
Switch, Table, Tabs, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
@@ -31,6 +31,7 @@ type Row = {
|
||||
benefitAmount: number;
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
allowOnSitePickup?: boolean;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[];
|
||||
detailImageUrls?: string[];
|
||||
@@ -49,6 +50,7 @@ type ProductFormValues = {
|
||||
benefitAmount?: number;
|
||||
status?: string;
|
||||
sortOrder?: number;
|
||||
allowOnSitePickup?: boolean;
|
||||
coverUrl?: string;
|
||||
carouselUrls?: string[];
|
||||
detailImageUrls?: string[];
|
||||
@@ -100,6 +102,7 @@ function buildProductPayload(v: ProductFormValues) {
|
||||
benefitAmount: v.benefitAmount,
|
||||
status: v.status,
|
||||
sortOrder: v.sortOrder,
|
||||
allowOnSitePickup: !!v.allowOnSitePickup,
|
||||
coverUrl: v.coverUrl,
|
||||
carouselUrls,
|
||||
detailImageUrls,
|
||||
@@ -217,6 +220,9 @@ function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
|
||||
<Form.Item name="sortOrder" label="排序">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面">
|
||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
@@ -266,6 +272,12 @@ export default function ProductsPage() {
|
||||
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
|
||||
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{PRODUCT_STATUS_LABELS[s] || s}</Tag> },
|
||||
{
|
||||
title: '现场取货',
|
||||
dataIndex: 'allowOnSitePickup',
|
||||
width: 90,
|
||||
render: (v: boolean) => (v ? <Tag color="green">允许</Tag> : <Tag>否</Tag>),
|
||||
},
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
@@ -357,7 +369,7 @@ export default function ProductsPage() {
|
||||
void reload();
|
||||
}} width={720}>
|
||||
<Form form={createForm} layout="vertical" initialValues={{
|
||||
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
|
||||
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, allowOnSitePickup: false,
|
||||
carouselUrls: [''], detailImageUrls: [''],
|
||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||
}}>
|
||||
|
||||
@@ -1,236 +1,244 @@
|
||||
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
|
||||
|
||||
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}-wxacode.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,
|
||||
page: values.page?.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="page"
|
||||
label="小程序落地页(选填)"
|
||||
extra="如 pages/home/index;留空则使用服务端环境变量 WX_MINI_PROMO_PAGE"
|
||||
>
|
||||
<Input placeholder="pages/home/index" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,9 +155,6 @@ export default function PromoCodeDetailPage() {
|
||||
<Descriptions.Item label="二维码 ID" span={2}>
|
||||
<Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="H5 落地链接" 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>
|
||||
@@ -179,7 +176,15 @@ export default function PromoCodeDetailPage() {
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="扫码次数" value={stats?.scanCount ?? detail.scanCount} />
|
||||
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
|
||||
</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}>
|
||||
@@ -195,11 +200,6 @@ export default function PromoCodeDetailPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="来源标记用户" value={stats?.sourceMarkedCount ?? '—'} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -7,9 +7,11 @@ export default defineAppConfig({
|
||||
'pages/product-detail/index',
|
||||
'pages/store-detail/index',
|
||||
'pages/order-confirm/index',
|
||||
'pages/order-confirm-pickup/index',
|
||||
'pages/pay/index',
|
||||
'pages/orders/index',
|
||||
'pages/order-detail/index',
|
||||
'pages/pickup-receive/index',
|
||||
'pages/addresses/index',
|
||||
'pages/address-edit/index',
|
||||
'pages/customer-service/index',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { captureIosJssdkEntryUrl } from '@dukang/weixin-sdk';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav';
|
||||
import { toast } from '../lib/api';
|
||||
import { capturePromoSceneAndTouchScan } from '../lib/promo';
|
||||
import { saveWechatLoginResult } from '../lib/pay-wechat';
|
||||
import { applyWechatShare } from '../lib/wechat-share';
|
||||
import { handleWechatAuthCallback } from '../lib/wechat-auth';
|
||||
@@ -29,13 +30,16 @@ function currentPagePathWithQuery(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* H5 App 根节点专用:不可用 useDidShow(App 无页面 Context)。
|
||||
* - 首次进页:捕获 iOS 签名 URL + 默认分享 + OAuth code 回调
|
||||
* - 路由/回前台:刷新默认分享卡片
|
||||
* H5 App 根节点:iOS 签名 URL + 默认分享 + OAuth code 回调。
|
||||
* 小程序:冷启动时捕获推广码 scene 并回传扫码埋点。
|
||||
*/
|
||||
export default function WechatShareBootstrap() {
|
||||
const handlingCode = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
void capturePromoSceneAndTouchScan();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.TARO_ENV !== 'h5') return;
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { request } from './api';
|
||||
|
||||
const PROMO_ID_KEY = 'dukang_promo_id';
|
||||
|
||||
/** 同一次进入只 touch 一次扫码计数,避免首页反复 onShow 刷量 */
|
||||
let lastScanTouchKey = '';
|
||||
|
||||
function safeDecode(raw: string): string {
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePromoId(raw: unknown): string | null {
|
||||
if (raw == null || raw === '') return null;
|
||||
const s = safeDecode(String(raw)).trim();
|
||||
// 小程序码 scene 写入的是推广活动数字 ID
|
||||
if (!/^\d+$/.test(s)) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
type EnterOptionsLike = {
|
||||
scene?: string | number;
|
||||
query?: Record<string, string | undefined>;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
/** 从启动/进入参数解析推广活动 ID(优先 query.scene,与 getwxacodeunlimit 一致) */
|
||||
export function extractPromoIdFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
|
||||
if (!opts) return null;
|
||||
const q = opts.query ?? {};
|
||||
return (
|
||||
normalizePromoId(q.scene) ||
|
||||
normalizePromoId(q.promoId) ||
|
||||
normalizePromoId(q.pid) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function getStoredPromoId(): string | null {
|
||||
try {
|
||||
const v = Taro.getStorageSync(PROMO_ID_KEY);
|
||||
return normalizePromoId(v);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredPromoId(promoId: string) {
|
||||
const id = normalizePromoId(promoId);
|
||||
if (!id) return;
|
||||
try {
|
||||
Taro.setStorageSync(PROMO_ID_KEY, id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function readEnterOptions(): EnterOptionsLike | null {
|
||||
try {
|
||||
if (typeof Taro.getEnterOptionsSync === 'function') {
|
||||
return Taro.getEnterOptionsSync() as EnterOptionsLike;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
if (typeof Taro.getLaunchOptionsSync === 'function') {
|
||||
return Taro.getLaunchOptionsSync() as EnterOptionsLike;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// H5:从 URL query 读取
|
||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
query: {
|
||||
scene: params.get('scene') || undefined,
|
||||
promoId: params.get('promoId') || undefined,
|
||||
pid: params.get('pid') || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主页面进入时:取出 scene(活动 ID)本地缓存,并回传 /promo/touch 累加扫码次数。
|
||||
* 同一进入会话只计一次扫码。
|
||||
*/
|
||||
export async function capturePromoSceneAndTouchScan(): Promise<void> {
|
||||
const opts = readEnterOptions();
|
||||
const fromEnter = extractPromoIdFromEnterOptions(opts);
|
||||
if (fromEnter) {
|
||||
setStoredPromoId(fromEnter);
|
||||
const touchKey = `${fromEnter}|${opts?.path || ''}|${JSON.stringify(opts?.query || {})}|${String(opts?.scene ?? '')}`;
|
||||
if (touchKey === lastScanTouchKey) return;
|
||||
lastScanTouchKey = touchKey;
|
||||
await touchPromo({ promoId: fromEnter, countScan: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// 无新 scene 时不重复扫码计数
|
||||
}
|
||||
|
||||
/** 登录成功后:用已缓存的活动 ID 做归因(不重复加扫码次数) */
|
||||
export async function touchStoredPromoAfterLogin(): Promise<void> {
|
||||
const promoId = getStoredPromoId();
|
||||
if (!promoId) return;
|
||||
await touchPromo({ promoId, countScan: false });
|
||||
}
|
||||
|
||||
async function touchPromo(input: { promoId: string; countScan: boolean }): Promise<void> {
|
||||
try {
|
||||
await request('/promo/touch', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
promoId: input.promoId,
|
||||
countScan: input.countScan,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* 静默失败,不阻断浏览 */
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
@@ -35,17 +35,18 @@ function usagePercent(coupon: CouponItem) {
|
||||
|
||||
export default function BenefitPage() {
|
||||
const metrics = useNavBarMetrics();
|
||||
const loggedIn = isLoggedIn();
|
||||
const [loggedIn, setLoggedIn] = useState(() => isLoggedIn());
|
||||
const [summary, setSummary] = useState<BenefitSummary | null>(null);
|
||||
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
||||
const [tab, setTab] = useState<'available' | 'history'>('available');
|
||||
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(2);
|
||||
});
|
||||
const resetGuestState = useCallback(() => {
|
||||
setSummary(null);
|
||||
setCoupons([]);
|
||||
}, []);
|
||||
|
||||
const loadBenefit = useCallback(() => {
|
||||
if (!loggedIn) return Promise.resolve();
|
||||
if (!isLoggedIn()) return Promise.resolve();
|
||||
return Promise.all([
|
||||
request<BenefitSummary>('/benefit/summary'),
|
||||
request<CouponItem[]>('/benefit/coupons'),
|
||||
@@ -55,13 +56,27 @@ export default function BenefitPage() {
|
||||
setCoupons(Array.isArray(list) ? list : []);
|
||||
})
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [loggedIn]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBenefit();
|
||||
}, [loadBenefit]);
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(2);
|
||||
const loggedInNow = isLoggedIn();
|
||||
setLoggedIn(loggedInNow);
|
||||
if (loggedInNow) {
|
||||
void loadBenefit();
|
||||
} else {
|
||||
resetGuestState();
|
||||
}
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
const loggedInNow = isLoggedIn();
|
||||
setLoggedIn(loggedInNow);
|
||||
if (!loggedInNow) {
|
||||
resetGuestState();
|
||||
Taro.stopPullDownRefresh();
|
||||
return;
|
||||
}
|
||||
void loadBenefit().finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import TabMainHeader from '../../components/TabMainHeader';
|
||||
import CouponBadge from '../../components/CouponBadge';
|
||||
import ProductCarousel from '../../components/ProductCarousel';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
|
||||
import { getProductImages } from '../../lib/product-images';
|
||||
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
||||
type Product = {
|
||||
@@ -18,6 +21,7 @@ type Product = {
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
aromaType: string;
|
||||
allowOnSitePickup?: boolean;
|
||||
};
|
||||
|
||||
const AROMA_TABS = [
|
||||
@@ -35,6 +39,7 @@ export default function HomePage() {
|
||||
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(0);
|
||||
void capturePromoSceneAndTouchScan();
|
||||
void resolveUserCity().then((resolved) => {
|
||||
setDisplayCity(resolved.displayCity);
|
||||
setCityCode(getCityCodeForCatalog(resolved));
|
||||
@@ -93,6 +98,17 @@ export default function HomePage() {
|
||||
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
|
||||
}
|
||||
|
||||
async function goOnSitePickup(productId: string) {
|
||||
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
|
||||
if (!isLoggedIn()) {
|
||||
goLogin(returnPath);
|
||||
return;
|
||||
}
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
Taro.navigateTo({ url: returnPath });
|
||||
}
|
||||
|
||||
const filtered = products.filter((p) => p.aromaType === tab);
|
||||
|
||||
return (
|
||||
@@ -138,6 +154,16 @@ export default function HomePage() {
|
||||
</View>
|
||||
</View>
|
||||
<View className="home-product-actions">
|
||||
{p.allowOnSitePickup ? (
|
||||
<Text
|
||||
className="home-pickup-btn"
|
||||
onClick={() => {
|
||||
void goOnSitePickup(p.id);
|
||||
}}
|
||||
>
|
||||
现场取货
|
||||
</Text>
|
||||
) : null}
|
||||
<Text className="home-buy-btn" onClick={() => openProductDetail(p.id)}>
|
||||
立即购买
|
||||
</Text>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type MiniWechatProfile,
|
||||
} from '../../lib/mini-wechat-profile';
|
||||
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
|
||||
import { touchStoredPromoAfterLogin } from '../../lib/promo';
|
||||
|
||||
const IS_WEAPP = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
@@ -176,6 +177,7 @@ export default function LoginPage() {
|
||||
refreshToken: data.refreshToken,
|
||||
});
|
||||
void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile());
|
||||
void touchStoredPromoAfterLogin();
|
||||
if (!phoneValue) {
|
||||
void fetchUserProfile()
|
||||
.then((me) => resolveDefaultUserPhone(me))
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '现场取货确认',
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import { request } from '../../lib/api';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
|
||||
type PreviewProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
spec?: string;
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
};
|
||||
|
||||
type OrderPreview = {
|
||||
product: PreviewProduct;
|
||||
quantity: number;
|
||||
deliveryType: string;
|
||||
productAmount: number;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
quantityOk?: boolean;
|
||||
quantityMessage?: string | null;
|
||||
minQty?: number;
|
||||
};
|
||||
|
||||
export default function OrderConfirmPickupPage() {
|
||||
const router = useRouter();
|
||||
const productId = router.params.productId ?? '';
|
||||
const [quantity, setQuantity] = useState(Math.max(1, Number(router.params.qty || 1)));
|
||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const phonePromptSkipped = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) return;
|
||||
let cancelled = false;
|
||||
setPreviewLoading(true);
|
||||
request<OrderPreview>('/trade/orders/preview', {
|
||||
method: 'POST',
|
||||
data: { productId, quantity, onSitePickup: true },
|
||||
})
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setPreview(data);
|
||||
setMsg(data.quantityOk === false ? data.quantityMessage || '' : '');
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '加载失败');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPreviewLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [productId, quantity]);
|
||||
|
||||
const minQty = preview?.minQty ?? 1;
|
||||
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
|
||||
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
|
||||
|
||||
function updateQuantity(next: number) {
|
||||
if (next < 1) return;
|
||||
setQuantity(next);
|
||||
}
|
||||
|
||||
async function doSubmit() {
|
||||
const order = await request<{ id: string }>('/trade/orders', {
|
||||
method: 'POST',
|
||||
data: { productId, quantity, onSitePickup: true },
|
||||
});
|
||||
Taro.redirectTo({
|
||||
url: buildPayUrl({
|
||||
orderId: order.id,
|
||||
productId,
|
||||
qty: String(quantity),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit) {
|
||||
if (!quantityOk) setMsg(`现场取货至少购买 ${minQty} 瓶`);
|
||||
return;
|
||||
}
|
||||
|
||||
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=${quantity}`;
|
||||
|
||||
if (!phonePromptSkipped.current) {
|
||||
try {
|
||||
const profile = await fetchUserProfile();
|
||||
const phoneBound =
|
||||
!!profile.phoneVerified ||
|
||||
(!!profile.phone && /^1[3-9]\d{9}$/.test(String(profile.phone)));
|
||||
if (!phoneBound) {
|
||||
const { confirm, cancel } = await Taro.showModal({
|
||||
title: '建议绑定手机号',
|
||||
content: '绑定后便于订单通知与售后联系;也可跳过,不绑定也能继续下单。',
|
||||
confirmText: '去绑定',
|
||||
cancelText: '暂不绑定',
|
||||
});
|
||||
if (confirm) {
|
||||
goLogin(returnPath, { needPhone: '1' });
|
||||
return;
|
||||
}
|
||||
if (cancel) {
|
||||
phonePromptSkipped.current = true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 拉取档案失败不阻塞下单 */
|
||||
}
|
||||
}
|
||||
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await doSubmit();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const productImage = preview?.product ? getProductMainImage(preview.product) : '';
|
||||
const submitLabel = loading
|
||||
? '提交中…'
|
||||
: !quantityOk
|
||||
? `至少购买 ${minQty} 瓶`
|
||||
: '提交订单';
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
|
||||
<SubPageHeader title="现场取货确认" />
|
||||
<View className="sub-page-body">
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">取货方式</Text>
|
||||
<Text className="u-muted">现场取货 · 无需填写收货地址 · 免运费</Text>
|
||||
</View>
|
||||
|
||||
{preview ? (
|
||||
<>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">商品信息</Text>
|
||||
<View className="order-product-row">
|
||||
<View className="order-product-thumb">
|
||||
{productImage ? (
|
||||
<Image
|
||||
className="order-product-thumb-img"
|
||||
src={productImage}
|
||||
mode="aspectFill"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text className="order-product-name">{preview.product.name}</Text>
|
||||
{preview.product.spec ? (
|
||||
<Text className="u-muted">{preview.product.spec}</Text>
|
||||
) : null}
|
||||
<Text className="order-product-price">
|
||||
¥{Number(preview.product.price).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-qty-row">
|
||||
<Text>购买数量</Text>
|
||||
<View className="order-qty-controls">
|
||||
<View
|
||||
className={`order-qty-btn${quantity <= 1 ? ' order-qty-btn--disabled' : ''}`}
|
||||
onClick={() => updateQuantity(quantity - 1)}
|
||||
>
|
||||
<Text>−</Text>
|
||||
</View>
|
||||
<Text className="order-qty-value">{quantity}</Text>
|
||||
<View className="order-qty-btn" onClick={() => updateQuantity(quantity + 1)}>
|
||||
<Text>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">费用明细</Text>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">商品金额</Text>
|
||||
<Text className="order-row-value">¥{Number(preview.productAmount).toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">好客权益</Text>
|
||||
<Text className="order-row-value--price">¥{Number(preview.benefitAmount).toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">运费</Text>
|
||||
<Text className="order-row-value">免运费</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : previewLoading ? (
|
||||
<View className="u-empty">加载订单信息…</View>
|
||||
) : null}
|
||||
|
||||
{msg ? (
|
||||
<Text className="u-muted" style={{ display: 'block', marginTop: 8 }}>
|
||||
{msg}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View className="order-confirm-bar">
|
||||
<View className="order-confirm-total">
|
||||
<Text className="order-confirm-total-label">应付合计</Text>
|
||||
<Text className="order-confirm-total-value">
|
||||
¥{preview ? Number(preview.payAmount).toFixed(2) : '—'}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`}
|
||||
onClick={() => {
|
||||
if (!canSubmit) return;
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<Text>{submitLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -56,16 +56,6 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
/** 已付款未完成:可选现场取货并确认收货 */
|
||||
const ON_SITE_PICKUP_STATUSES = new Set([
|
||||
'PENDING_SHIP',
|
||||
'OUT_WAREHOUSE',
|
||||
'SHIPPING',
|
||||
'SHIPPED',
|
||||
'PENDING_RECEIVE',
|
||||
'DELIVERED',
|
||||
]);
|
||||
|
||||
function fullReceiverAddress(order: OrderDetail) {
|
||||
const detail = (order.receiverAddress || '').trim();
|
||||
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
||||
@@ -80,7 +70,6 @@ export default function OrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const orderId = router.params.id ?? '';
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
const [onSitePickup, setOnSitePickup] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -92,13 +81,8 @@ export default function OrderDetailPage() {
|
||||
|
||||
const isReship = !!order?.originOrderId;
|
||||
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
|
||||
const canOnSitePickup =
|
||||
!!order && !isReship && ON_SITE_PICKUP_STATUSES.has(order.status || '');
|
||||
const canConfirmReceive =
|
||||
!!order &&
|
||||
!isReship &&
|
||||
(['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '') ||
|
||||
(onSitePickup && canOnSitePickup));
|
||||
!!order && !isReship && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
|
||||
|
||||
const item = order?.items?.[0];
|
||||
const productName = item?.productName || order?.productName || '杜康商品';
|
||||
@@ -137,13 +121,9 @@ export default function OrderDetailPage() {
|
||||
async function confirmReceive() {
|
||||
if (!order || !canConfirmReceive || confirming) return;
|
||||
|
||||
const useOnSite =
|
||||
onSitePickup || !['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: useOnSite ? '确认现场取货?' : '确认收货?',
|
||||
content: useOnSite
|
||||
? '请确认您已在现场拿到商品。确认后订单将完成,好客权益即时可用,无法再安排配送。若尚未取到酒,请勿确认。'
|
||||
: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
|
||||
title: '确认收货?',
|
||||
content: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
|
||||
confirmText: '确认收货',
|
||||
cancelText: '再想想',
|
||||
});
|
||||
@@ -153,11 +133,10 @@ export default function OrderDetailPage() {
|
||||
try {
|
||||
const updated = await request<OrderDetail>(`/trade/orders/${order.id}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: { onSitePickup: useOnSite },
|
||||
data: {},
|
||||
});
|
||||
setOrder(updated);
|
||||
setOnSitePickup(false);
|
||||
toast(useOnSite ? '现场取货已确认,订单完成' : '已确认收货');
|
||||
toast('已确认收货');
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '确认收货失败');
|
||||
} finally {
|
||||
@@ -225,27 +204,6 @@ export default function OrderDetailPage() {
|
||||
<Text className="u-muted">地址信息待完善</Text>
|
||||
)}
|
||||
</View>
|
||||
{canOnSitePickup ? (
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">取货方式</Text>
|
||||
<View
|
||||
className="order-pickup-option"
|
||||
onClick={() => setOnSitePickup((v) => !v)}
|
||||
>
|
||||
<View
|
||||
className={`order-pickup-check${onSitePickup ? ' order-pickup-check--on' : ''}`}
|
||||
>
|
||||
{onSitePickup ? <Text className="order-pickup-check-mark">✓</Text> : null}
|
||||
</View>
|
||||
<View className="order-pickup-copy">
|
||||
<Text className="order-pickup-title">现场取货</Text>
|
||||
<Text className="order-pickup-desc">
|
||||
已在活动现场或门店拿到商品时勾选,确认后订单直接完成
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">订单信息</Text>
|
||||
<View className="order-row">
|
||||
@@ -303,7 +261,7 @@ export default function OrderDetailPage() {
|
||||
className={`order-confirm-submit${confirming ? ' order-confirm-submit--disabled' : ''}`}
|
||||
onClick={confirming ? undefined : () => void confirmReceive()}
|
||||
>
|
||||
{confirming ? '提交中…' : onSitePickup ? '确认现场取货' : '确认收货'}
|
||||
{confirming ? '提交中…' : '确认收货'}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function PayPage() {
|
||||
const [msg, setMsg] = useState('');
|
||||
const [orderNo, setOrderNo] = useState('');
|
||||
const [payAmount, setPayAmount] = useState('—');
|
||||
const [deliveryType, setDeliveryType] = useState('');
|
||||
|
||||
const returnPath = orderId
|
||||
? `/pages/pay/index?orderId=${orderId}`
|
||||
@@ -62,11 +63,15 @@ export default function PayPage() {
|
||||
setPayAmount('—');
|
||||
return;
|
||||
}
|
||||
request<{ orderNo?: string; payAmount?: number | string; totalAmount?: number | string }>(
|
||||
`/trade/orders/${orderId}`,
|
||||
)
|
||||
request<{
|
||||
orderNo?: string;
|
||||
payAmount?: number | string;
|
||||
totalAmount?: number | string;
|
||||
deliveryType?: string;
|
||||
}>(`/trade/orders/${orderId}`)
|
||||
.then((order) => {
|
||||
setOrderNo(order.orderNo || '');
|
||||
setDeliveryType(order.deliveryType || '');
|
||||
const amount = Number(order.payAmount ?? order.totalAmount ?? 0);
|
||||
if (Number.isFinite(amount) && amount > 0) {
|
||||
setPayAmount(amount.toFixed(2));
|
||||
@@ -74,6 +79,7 @@ export default function PayPage() {
|
||||
})
|
||||
.catch((e) => {
|
||||
setOrderNo('');
|
||||
setDeliveryType('');
|
||||
toast(e instanceof Error ? e.message : '加载订单失败');
|
||||
});
|
||||
}, [orderId]);
|
||||
@@ -137,7 +143,11 @@ export default function PayPage() {
|
||||
} else {
|
||||
toast('支付成功', 'success');
|
||||
}
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=paid' });
|
||||
if (deliveryType === 'ON_SITE_PICKUP') {
|
||||
Taro.redirectTo({ url: `/pages/pickup-receive/index?id=${orderId}` });
|
||||
} else {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=paid' });
|
||||
}
|
||||
} catch (e) {
|
||||
if (isWechatAuthRequiredError(e)) {
|
||||
setNeedsWechatAuth(true);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '确认收货',
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
|
||||
type OrderDetail = {
|
||||
id: string;
|
||||
orderNo?: string;
|
||||
status?: string;
|
||||
payAmount?: number | string;
|
||||
productName?: string;
|
||||
productSpec?: string;
|
||||
quantity?: number;
|
||||
product?: {
|
||||
name?: string;
|
||||
spec?: string;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
};
|
||||
imageUrl?: string | null;
|
||||
mainImageUrl?: string | null;
|
||||
};
|
||||
|
||||
export default function PickupReceivePage() {
|
||||
const router = useRouter();
|
||||
const orderId = router.params.id ?? router.params.orderId ?? '';
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!orderId) return;
|
||||
setLoading(true);
|
||||
request<OrderDetail>(`/trade/orders/${orderId}`)
|
||||
.then((data) => setOrder(data))
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [orderId]);
|
||||
|
||||
useDidShow(() => {
|
||||
load();
|
||||
});
|
||||
|
||||
async function confirmReceive() {
|
||||
if (!orderId || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request(`/trade/orders/${orderId}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: {},
|
||||
});
|
||||
toast('确认收货成功', 'success');
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=done' });
|
||||
}, 500);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '确认失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const name = order?.productName || order?.product?.name || '商品';
|
||||
const spec = order?.productSpec || order?.product?.spec;
|
||||
const image =
|
||||
order?.mainImageUrl ||
|
||||
order?.imageUrl ||
|
||||
(order?.product ? getProductMainImage(order.product) : '') ||
|
||||
'';
|
||||
const amount = Number(order?.payAmount ?? 0);
|
||||
const canConfirm = order?.status === 'PENDING_RECEIVE' || order?.status === 'DELIVERED';
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
|
||||
<SubPageHeader title="确认收货" />
|
||||
<View className="sub-page-body">
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">现场取货</Text>
|
||||
<Text className="u-muted">请确认已在现场拿到商品后再点击确认收货</Text>
|
||||
</View>
|
||||
|
||||
{loading && !order ? (
|
||||
<View className="order-card">
|
||||
<Text className="u-muted">加载中…</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{order ? (
|
||||
<>
|
||||
<View className="order-card">
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">订单号</Text>
|
||||
<Text className="order-row-value">{order.orderNo || '—'}</Text>
|
||||
</View>
|
||||
<View className="order-product-row" style={{ marginTop: 12 }}>
|
||||
<View className="order-product-thumb">
|
||||
{image ? (
|
||||
<Image className="order-product-thumb-img" src={image} mode="aspectFill" />
|
||||
) : null}
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text className="order-product-name">{name}</Text>
|
||||
{spec ? <Text className="u-muted">{spec}</Text> : null}
|
||||
<Text className="u-muted">×{order.quantity ?? 1}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="order-card">
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">实付金额</Text>
|
||||
<Text className="order-row-value order-pay-amount">
|
||||
¥{Number.isFinite(amount) ? amount.toFixed(2) : '—'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
<View className="pay-bar">
|
||||
<View
|
||||
className="order-confirm-submit"
|
||||
style={{ flex: 1, opacity: canConfirm && !submitting ? 1 : 0.6 }}
|
||||
onClick={() => {
|
||||
if (!canConfirm || submitting) return;
|
||||
void confirmReceive();
|
||||
}}
|
||||
>
|
||||
<Text>{submitting ? '提交中…' : canConfirm ? '确认收货' : '订单状态不可确认'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,12 @@ function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/** 核销金额输入:最多两位小数;去掉前导 0;禁止非法字符 */
|
||||
/**
|
||||
* 核销金额输入清洗:
|
||||
* - 只保留数字与一个小数点
|
||||
* - 小数最多 2 位(再输入会被截断)
|
||||
* - 去掉多余前导 0
|
||||
*/
|
||||
function sanitizeRedeemAmountInput(raw: string): string {
|
||||
let next = String(raw ?? '').replace(/[^\d.]/g, '');
|
||||
if (!next) return '';
|
||||
@@ -28,19 +33,18 @@ function sanitizeRedeemAmountInput(raw: string): string {
|
||||
const decRaw = next
|
||||
.slice(firstDot + 1)
|
||||
.replace(/\D/g, '')
|
||||
.replace(/\./g, '')
|
||||
.slice(0, 2);
|
||||
const intPart = intRaw.replace(/^0+(?=\d)/, '') || '0';
|
||||
// 正在输入小数点或小数位时保留点
|
||||
if (decRaw.length > 0 || next.endsWith('.')) {
|
||||
if (next.endsWith('.') && decRaw.length === 0) {
|
||||
return `${intPart}.`;
|
||||
}
|
||||
if (decRaw.length > 0) {
|
||||
return `${intPart}.${decRaw}`;
|
||||
}
|
||||
return intPart;
|
||||
}
|
||||
|
||||
// 纯整数:忽略前导 0(保留单个 0)
|
||||
next = next.replace(/^0+(?=\d)/, '');
|
||||
return next;
|
||||
return next.replace(/^0+(?=\d)/, '');
|
||||
}
|
||||
|
||||
export default function RedeemPage() {
|
||||
@@ -50,6 +54,8 @@ export default function RedeemPage() {
|
||||
const [balance, setBalance] = useState(0);
|
||||
const [couponBalance, setCouponBalance] = useState<number | null>(null);
|
||||
const [amount, setAmount] = useState(() => sanitizeRedeemAmountInput(initialAmount));
|
||||
/** 原生 input 在截断小数后偶发不同步,强制 remount 对齐受控值 */
|
||||
const [inputKey, setInputKey] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const redeemableMax = couponId ? (couponBalance ?? 0) : balance;
|
||||
@@ -80,10 +86,16 @@ export default function RedeemPage() {
|
||||
function fillMaxAmount() {
|
||||
if (redeemableMax < MIN_REDEEM_AMOUNT) return;
|
||||
setAmount(sanitizeRedeemAmountInput(redeemableMax.toFixed(2)));
|
||||
setInputKey((k) => k + 1);
|
||||
}
|
||||
|
||||
function onAmountChange(raw: string) {
|
||||
setAmount(sanitizeRedeemAmountInput(raw));
|
||||
const next = sanitizeRedeemAmountInput(raw);
|
||||
setAmount(next);
|
||||
// 用户试图输入超过两位小数 / 非法字符时,强制刷新原生框显示
|
||||
if (next !== raw) {
|
||||
setInputKey((k) => k + 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
@@ -130,11 +142,13 @@ export default function RedeemPage() {
|
||||
</View>
|
||||
<View className="redeem-input-wrap">
|
||||
<Input
|
||||
key={inputKey}
|
||||
className="redeem-input"
|
||||
type="digit"
|
||||
placeholder="输入核销金额"
|
||||
placeholderClass="redeem-input-placeholder"
|
||||
value={amount}
|
||||
maxlength={12}
|
||||
onInput={(e) => onAmountChange(e.detail.value)}
|
||||
onBlur={(e) => onAmountChange(e.detail.value)}
|
||||
style={{ textAlign: 'center' }}
|
||||
@@ -150,7 +164,7 @@ export default function RedeemPage() {
|
||||
</View>
|
||||
<View className="redeem-tips">
|
||||
<Text className="redeem-tips-text">
|
||||
核销金额最低 0.01 元,且不超过可用权益余额。核销码有效期 3 分钟,请到店出示给收银员扫码。
|
||||
核销金额最低 0.01 元,小数最多两位。不超过可用权益余额。核销码有效期 3 分钟,请到店出示给收银员扫码。
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
|
||||
@@ -191,6 +191,20 @@
|
||||
padding: 0 var(--space-gutter) var(--space-gutter);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.home-pickup-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-full);
|
||||
background: #2e7d32;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.home-buy-btn {
|
||||
|
||||
@@ -73,58 +73,6 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.order-pickup-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.order-pickup-check {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
border: 1.5px solid var(--color-outline, #c8c4be);
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.order-pickup-check--on {
|
||||
border-color: var(--color-primary, #8b1a1a);
|
||||
background: var(--color-primary, #8b1a1a);
|
||||
}
|
||||
|
||||
.order-pickup-check-mark {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.order-pickup-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.order-pickup-title {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.order-pickup-desc {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-on-surface-variant, #78716c);
|
||||
}
|
||||
|
||||
.order-card {
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-lg);
|
||||
|
||||
@@ -32,6 +32,11 @@ describe('validateMinPurchase', () => {
|
||||
expect(validateMinPurchase('CROSS_CITY', 5, 2, 6).ok).toBe(false);
|
||||
expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('on-site pickup requires at least 1 bottle', () => {
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 0, 2, 6).ok).toBe(false);
|
||||
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRedeemAmount', () => {
|
||||
|
||||
@@ -8,11 +8,17 @@ export function calcBenefitAmount(product: ProductPricing): number {
|
||||
}
|
||||
|
||||
export function validateMinPurchase(
|
||||
deliveryType: 'LOCAL' | 'CROSS_CITY',
|
||||
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
|
||||
quantity: number,
|
||||
localMinQty: number,
|
||||
crossMinQty: number,
|
||||
): { ok: boolean; message?: string } {
|
||||
if (deliveryType === 'ON_SITE_PICKUP') {
|
||||
if (quantity < 1) {
|
||||
return { ok: false, message: '现场取货至少购买 1 瓶' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
const min = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
|
||||
if (quantity < min) {
|
||||
return {
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface ProductDto {
|
||||
/** 详情长图(bizType=DETAIL 或 detailContent JSON) */
|
||||
detailImageUrls?: string[];
|
||||
detailContent?: ProductDetailContentDto | null;
|
||||
/** 是否允许现场取货下单 */
|
||||
allowOnSitePickup?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductDetailFeatureDto {
|
||||
|
||||
@@ -91,6 +91,7 @@ export enum OrderTab {
|
||||
export enum DeliveryType {
|
||||
LOCAL = 'LOCAL',
|
||||
CROSS_CITY = 'CROSS_CITY',
|
||||
ON_SITE_PICKUP = 'ON_SITE_PICKUP',
|
||||
}
|
||||
|
||||
export enum AromaType {
|
||||
|
||||
@@ -45,12 +45,16 @@ export type PromoCodeItem = {
|
||||
};
|
||||
|
||||
export type PromoCodeStats = {
|
||||
/** 扫码进入次数 */
|
||||
scanCount: number;
|
||||
orderCount: number;
|
||||
conversionRate: number;
|
||||
/** 归因用户数(user_promo_attribution) */
|
||||
attributionCount?: number;
|
||||
/** 用户表 source_ref_id 指向本推广码的用户数 */
|
||||
/** 扫码注册用户数:用户来源标记为本推广码 */
|
||||
sourceMarkedCount?: number;
|
||||
/** @deprecated 同 sourceMarkedCount,兼容旧字段名 */
|
||||
registerCount?: number;
|
||||
};
|
||||
|
||||
export type PromoCodeAttributedUser = {
|
||||
|
||||
@@ -262,6 +262,7 @@ enum PayStatus {
|
||||
enum DeliveryType {
|
||||
LOCAL
|
||||
CROSS_CITY
|
||||
ON_SITE_PICKUP
|
||||
}
|
||||
|
||||
enum FreightPayType {
|
||||
@@ -451,6 +452,7 @@ model CommonProductItem {
|
||||
benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2)
|
||||
status ProductStatus @default(DRAFT)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
allowOnSitePickup Boolean @default(false) @map("allow_on_site_pickup")
|
||||
coverResourceId BigInt? @map("cover_resource_id") @db.UnsignedBigInt
|
||||
detailContent Json? @map("detail_content")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
@@ -26,7 +26,12 @@ export class PromoController {
|
||||
touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
|
||||
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
|
||||
return this.promoCodeService.touch(
|
||||
{ promoCode: dto.promoCode, qrcodeId: dto.qrcodeId, promoId: dto.promoId },
|
||||
{
|
||||
promoCode: dto.promoCode,
|
||||
qrcodeId: dto.qrcodeId,
|
||||
promoId: dto.promoId,
|
||||
countScan: dto.countScan,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
export class PromoTouchDto {
|
||||
@IsOptional()
|
||||
@@ -13,4 +14,17 @@ export class PromoTouchDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoId?: string;
|
||||
|
||||
/**
|
||||
* 是否累加扫码次数。扫码进入为 true;登录后归因可传 false,避免重复计数。
|
||||
* 默认 true。
|
||||
*/
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === false || value === 'false' || value === 0 || value === '0') return false;
|
||||
if (value === true || value === 'true' || value === 1 || value === '1') return true;
|
||||
return undefined;
|
||||
})
|
||||
@IsBoolean()
|
||||
countScan?: boolean;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ export class AdminProductsService {
|
||||
benefitAmount: dto.benefitAmount ?? dto.price,
|
||||
status: (dto.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE',
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
allowOnSitePickup: dto.allowOnSitePickup ?? false,
|
||||
...(dto.detailContent !== undefined
|
||||
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
@@ -118,6 +119,7 @@ export class AdminProductsService {
|
||||
...(dto.benefitAmount !== undefined ? { benefitAmount: dto.benefitAmount } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status as 'DRAFT' | 'ON_SALE' | 'OFF_SALE' } : {}),
|
||||
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||
...(dto.allowOnSitePickup !== undefined ? { allowOnSitePickup: dto.allowOnSitePickup } : {}),
|
||||
...(dto.detailContent !== undefined
|
||||
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
|
||||
: {}),
|
||||
|
||||
@@ -947,6 +947,10 @@ export class CreateProductDto {
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowOnSitePickup?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
coverUrl?: string;
|
||||
@@ -995,6 +999,10 @@ export class UpdateProductDto {
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowOnSitePickup?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
coverUrl?: string;
|
||||
|
||||
@@ -55,6 +55,12 @@ export class CreatePromoCodeDto {
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
remark?: string;
|
||||
|
||||
/** 小程序码落地页路径,如 pages/home/index;留空用环境变量 WX_MINI_PROMO_PAGE */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
page?: string;
|
||||
}
|
||||
|
||||
export class UpdatePromoCodeDto {
|
||||
|
||||
@@ -235,14 +235,19 @@ export class PromoCodeService {
|
||||
/**
|
||||
* 调用微信 getwxacodeunlimit,scene=推广活动 ID,PNG 上传 OSS uploads/qrcode/
|
||||
*/
|
||||
private async createQrcodeResource(promoId: bigint, code: string) {
|
||||
private async createQrcodeResource(promoId: bigint, code: string, pagePath?: string) {
|
||||
const scene = promoId.toString();
|
||||
if (scene.length > 32) {
|
||||
throw new BadRequestException('推广活动 ID 过长,无法写入小程序码 scene');
|
||||
}
|
||||
const page = (
|
||||
pagePath?.trim() ||
|
||||
process.env.WX_MINI_PROMO_PAGE ||
|
||||
'pages/home/index'
|
||||
).replace(/^\//, '');
|
||||
const pngBuffer = await this.wechat.getWxaCodeUnlimited({
|
||||
scene,
|
||||
page: process.env.WX_MINI_PROMO_PAGE || 'pages/home/index',
|
||||
page,
|
||||
width: 430,
|
||||
checkPath: false,
|
||||
});
|
||||
@@ -290,7 +295,7 @@ export class PromoCodeService {
|
||||
});
|
||||
|
||||
try {
|
||||
const resource = await this.createQrcodeResource(row.id, code);
|
||||
const resource = await this.createQrcodeResource(row.id, code, dto.page);
|
||||
const updated = await this.prisma.commonPromoCode.update({
|
||||
where: { id: row.id },
|
||||
data: { qrcodeResourceId: resource.id },
|
||||
@@ -380,7 +385,13 @@ export class PromoCodeService {
|
||||
|
||||
/** C 端扫码/带参进入:累加 scan_count、归因、标记用户来源 */
|
||||
async touch(
|
||||
input: { promoCode?: string; qrcodeId?: string; promoId?: string },
|
||||
input: {
|
||||
promoCode?: string;
|
||||
qrcodeId?: string;
|
||||
promoId?: string;
|
||||
/** 默认 true;登录后归因传 false 避免重复计扫码 */
|
||||
countScan?: boolean;
|
||||
},
|
||||
userId?: bigint,
|
||||
) {
|
||||
const promoCode = input.promoCode?.trim().toUpperCase();
|
||||
@@ -395,10 +406,13 @@ export class PromoCodeService {
|
||||
throw new NotFoundException('推广码无效或已停用');
|
||||
}
|
||||
|
||||
await this.prisma.commonPromoCode.update({
|
||||
where: { id: promo.id },
|
||||
data: { scanCount: { increment: 1 } },
|
||||
});
|
||||
const shouldCountScan = input.countScan !== false;
|
||||
if (shouldCountScan) {
|
||||
await this.prisma.commonPromoCode.update({
|
||||
where: { id: promo.id },
|
||||
data: { scanCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
let attributed = false;
|
||||
let sourceApplied = false;
|
||||
@@ -428,6 +442,7 @@ export class PromoCodeService {
|
||||
channelName: promo.name,
|
||||
attributed,
|
||||
sourceApplied,
|
||||
scanCounted: shouldCountScan,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -472,7 +487,14 @@ export class PromoCodeService {
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { scanCount, orderCount, conversionRate, attributionCount, sourceMarkedCount };
|
||||
return {
|
||||
scanCount,
|
||||
orderCount,
|
||||
conversionRate,
|
||||
attributionCount,
|
||||
sourceMarkedCount,
|
||||
registerCount: sourceMarkedCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** 推广码关联用户:归因记录或用户来源指向本码 */
|
||||
|
||||
@@ -51,7 +51,10 @@ export class TradeService {
|
||||
private readonly fulfillmentService: FulfillmentService,
|
||||
) {}
|
||||
|
||||
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
|
||||
async preview(
|
||||
userId: bigint,
|
||||
body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean },
|
||||
) {
|
||||
const product = await this.catalogService.getProduct(BigInt(body.productId));
|
||||
if (!product || product.status !== 'ON_SALE') {
|
||||
throw new BadRequestException('商品不可购买');
|
||||
@@ -59,8 +62,15 @@ export class TradeService {
|
||||
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
|
||||
if (!city) throw new BadRequestException('暂无开城城市');
|
||||
|
||||
let deliveryType: 'LOCAL' | 'CROSS_CITY' = 'LOCAL';
|
||||
if (body.addressId) {
|
||||
const onSitePickup = !!body.onSitePickup;
|
||||
if (onSitePickup && !product.allowOnSitePickup) {
|
||||
throw new BadRequestException('该商品不支持现场取货');
|
||||
}
|
||||
|
||||
let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = onSitePickup
|
||||
? 'ON_SITE_PICKUP'
|
||||
: 'LOCAL';
|
||||
if (!onSitePickup && body.addressId) {
|
||||
const address = await this.prisma.userAddress.findFirst({
|
||||
where: { id: BigInt(body.addressId), userId },
|
||||
});
|
||||
@@ -84,13 +94,19 @@ export class TradeService {
|
||||
});
|
||||
|
||||
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
|
||||
const minQty =
|
||||
deliveryType === 'ON_SITE_PICKUP'
|
||||
? 1
|
||||
: deliveryType === 'LOCAL'
|
||||
? city.localMinQty
|
||||
: city.crossMinQty;
|
||||
|
||||
return {
|
||||
product,
|
||||
quantity: body.quantity,
|
||||
deliveryType,
|
||||
productAmount,
|
||||
freightAmount: deliveryType === 'CROSS_CITY' ? 0 : 0,
|
||||
freightAmount: 0,
|
||||
freightPayType,
|
||||
payAmount: productAmount,
|
||||
benefitAmount: benefitPerUnit * body.quantity,
|
||||
@@ -98,7 +114,8 @@ export class TradeService {
|
||||
/** 起购未满足时仍返回预览,供确认页改数量;下单接口仍会硬校验 */
|
||||
quantityOk: check.ok,
|
||||
quantityMessage: check.ok ? null : (check.message ?? null),
|
||||
minQty: deliveryType === 'LOCAL' ? city.localMinQty : city.crossMinQty,
|
||||
minQty,
|
||||
onSitePickup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,7 +124,8 @@ export class TradeService {
|
||||
body: {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
addressId: string;
|
||||
addressId?: string;
|
||||
onSitePickup?: boolean;
|
||||
clientLocation?: unknown;
|
||||
},
|
||||
req: Request,
|
||||
@@ -116,10 +134,35 @@ export class TradeService {
|
||||
if (preview.quantityOk === false) {
|
||||
throw new BadRequestException(preview.quantityMessage || '购买数量不满足起购要求');
|
||||
}
|
||||
const address = await this.prisma.userAddress.findFirst({
|
||||
where: { id: BigInt(body.addressId), userId },
|
||||
});
|
||||
if (!address) throw new BadRequestException('请选择收货地址');
|
||||
|
||||
const onSitePickup = !!body.onSitePickup || preview.deliveryType === 'ON_SITE_PICKUP';
|
||||
let receiverName = '现场取货';
|
||||
let receiverPhone = '00000000000';
|
||||
let receiverAddress = '现场取货';
|
||||
let receiverProvince = '';
|
||||
let receiverCity = '';
|
||||
let receiverDistrict = '';
|
||||
|
||||
if (onSitePickup) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
receiverPhone = user?.phone || '00000000000';
|
||||
receiverName = (user?.nickname?.trim() || '现场取货').slice(0, 32);
|
||||
receiverProvince = '现场';
|
||||
receiverCity = '现场';
|
||||
receiverDistrict = '取货';
|
||||
} else {
|
||||
if (!body.addressId) throw new BadRequestException('请选择收货地址');
|
||||
const address = await this.prisma.userAddress.findFirst({
|
||||
where: { id: BigInt(body.addressId), userId },
|
||||
});
|
||||
if (!address) throw new BadRequestException('请选择收货地址');
|
||||
receiverName = address.receiverName;
|
||||
receiverPhone = address.phone;
|
||||
receiverAddress = `${address.province}${address.city}${address.district}${address.detail}`;
|
||||
receiverProvince = address.province;
|
||||
receiverCity = address.city;
|
||||
receiverDistrict = address.district;
|
||||
}
|
||||
|
||||
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
|
||||
where: { id: BigInt(body.productId) },
|
||||
@@ -149,7 +192,7 @@ export class TradeService {
|
||||
cityId: city.id,
|
||||
status: 'PENDING_PAY',
|
||||
payStatus: 'UNPAID',
|
||||
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY',
|
||||
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
|
||||
productId: product.id,
|
||||
barcode69: product.barcode69,
|
||||
productName: product.name,
|
||||
@@ -159,12 +202,12 @@ export class TradeService {
|
||||
listUnitPrice: product.price,
|
||||
listAmount: preview.productAmount,
|
||||
productAmount: preview.productAmount,
|
||||
receiverName: address.receiverName,
|
||||
receiverPhone: address.phone,
|
||||
receiverAddress: `${address.province}${address.city}${address.district}${address.detail}`,
|
||||
receiverProvince: address.province,
|
||||
receiverCity: address.city,
|
||||
receiverDistrict: address.district,
|
||||
receiverName,
|
||||
receiverPhone,
|
||||
receiverAddress,
|
||||
receiverProvince,
|
||||
receiverCity,
|
||||
receiverDistrict,
|
||||
clientIp: location.clientIp,
|
||||
ipProvince: location.ipProvince,
|
||||
ipCity: location.ipCity,
|
||||
@@ -203,6 +246,7 @@ export class TradeService {
|
||||
orderId: order.id.toString(),
|
||||
productId: body.productId,
|
||||
quantity: body.quantity,
|
||||
onSitePickup,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -241,12 +285,14 @@ export class TradeService {
|
||||
order.cityId,
|
||||
order.receiverDistrict,
|
||||
);
|
||||
const toStatus =
|
||||
order.deliveryType === 'ON_SITE_PICKUP' ? 'PENDING_RECEIVE' : 'PENDING_SHIP';
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.order.update({
|
||||
where: { id: order.id },
|
||||
data: {
|
||||
status: 'PENDING_SHIP',
|
||||
status: toStatus,
|
||||
payStatus: 'PAID',
|
||||
paidAt: now,
|
||||
payExternalNo: externalNo,
|
||||
@@ -269,7 +315,7 @@ export class TradeService {
|
||||
data: buildOrderStatusEvent({
|
||||
orderId: order.id,
|
||||
fromStatus: 'PENDING_PAY',
|
||||
toStatus: 'PENDING_SHIP',
|
||||
toStatus,
|
||||
operator: 'MOCK_PAY',
|
||||
}),
|
||||
});
|
||||
@@ -292,6 +338,20 @@ export class TradeService {
|
||||
|
||||
private async afterOrderPaid(orderId: bigint) {
|
||||
await this.benefitService.grantOnOrderPaid(orderId);
|
||||
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) return;
|
||||
|
||||
const delivery = await this.prisma.orderDelivery.findUnique({ where: { orderId } });
|
||||
if (!delivery) {
|
||||
await this.prisma.orderDelivery.create({
|
||||
data: { orderId, provider: 'MANUAL' },
|
||||
});
|
||||
}
|
||||
|
||||
if (order.deliveryType === 'ON_SITE_PICKUP') {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.fulfillmentService.dispatchAfterPay(orderId);
|
||||
const refreshed = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
if (refreshed?.status === 'PENDING_SHIP') {
|
||||
@@ -335,6 +395,8 @@ export class TradeService {
|
||||
order.cityId,
|
||||
order.receiverDistrict,
|
||||
);
|
||||
const toStatus =
|
||||
order.deliveryType === 'ON_SITE_PICKUP' ? 'PENDING_RECEIVE' : 'PENDING_SHIP';
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.order.findUnique({ where: { id: order.id } });
|
||||
if (!current || current.payStatus === 'PAID') return;
|
||||
@@ -342,7 +404,7 @@ export class TradeService {
|
||||
await tx.order.update({
|
||||
where: { id: order.id },
|
||||
data: {
|
||||
status: 'PENDING_SHIP',
|
||||
status: toStatus,
|
||||
payStatus: 'PAID',
|
||||
paidAt: now,
|
||||
payExternalNo: params.transactionId,
|
||||
@@ -365,7 +427,7 @@ export class TradeService {
|
||||
data: buildOrderStatusEvent({
|
||||
orderId: order.id,
|
||||
fromStatus: 'PENDING_PAY',
|
||||
toStatus: 'PENDING_SHIP',
|
||||
toStatus,
|
||||
operator: 'WECHAT_PAY',
|
||||
}),
|
||||
});
|
||||
@@ -470,25 +532,12 @@ export class TradeService {
|
||||
async confirmReceive(
|
||||
userId: bigint,
|
||||
orderId: bigint,
|
||||
opts?: { onSitePickup?: boolean },
|
||||
_opts?: { onSitePickup?: boolean },
|
||||
) {
|
||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
|
||||
const onSitePickup = !!opts?.onSitePickup;
|
||||
const onSiteEligible = [
|
||||
'PENDING_SHIP',
|
||||
'OUT_WAREHOUSE',
|
||||
'SHIPPING',
|
||||
'SHIPPED',
|
||||
'PENDING_RECEIVE',
|
||||
'DELIVERED',
|
||||
];
|
||||
if (onSitePickup) {
|
||||
if (!onSiteEligible.includes(order.status)) {
|
||||
throw new BadRequestException('当前状态不可现场取货');
|
||||
}
|
||||
} else if (!['PENDING_RECEIVE', 'DELIVERED'].includes(order.status)) {
|
||||
if (!['PENDING_RECEIVE', 'DELIVERED'].includes(order.status)) {
|
||||
throw new BadRequestException('当前状态不可确认收货');
|
||||
}
|
||||
|
||||
@@ -496,8 +545,8 @@ export class TradeService {
|
||||
order.id,
|
||||
order.status,
|
||||
'COMPLETED',
|
||||
onSitePickup ? 'USER_ON_SITE' : 'USER',
|
||||
onSitePickup ? '用户现场取货确认收货' : undefined,
|
||||
order.deliveryType === 'ON_SITE_PICKUP' ? 'USER_ON_SITE' : 'USER',
|
||||
order.deliveryType === 'ON_SITE_PICKUP' ? '用户现场取货确认收货' : undefined,
|
||||
);
|
||||
return this.getOrder(userId, orderId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user