Compare commits

...

3 Commits

Author SHA1 Message Date
jacy cebeefdb22 feat(trade): add on-site pickup order flow
CI / verify (pull_request) Has been cancelled
Product flag, ON_SITE_PICKUP delivery, mini confirm/receive pages; pay skips warehouse auto-advance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 01:17:59 +08:00
jacy e93e4a7721 推广码后端页面优化 2026-07-23 00:43:49 +08:00
jacy cd6b82abd8 小程序端核销权益输入框限制
CI / verify (pull_request) Has been cancelled
2026-07-23 00:32:09 +08:00
31 changed files with 1084 additions and 425 deletions
+14 -2
View File
@@ -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: '' }],
}}>
+244 -236
View File
@@ -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
+2
View File
@@ -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 根节点专用:不可用 useDidShowApp 无页面 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;
+129
View File
@@ -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 {
/* 静默失败,不阻断浏览 */
}
}
+25 -10
View File
@@ -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());
});
+27 -1
View File
@@ -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>
+2
View File
@@ -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>
+14 -4
View File
@@ -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>
);
}
+23 -9
View File
@@ -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
+14
View File
@@ -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 {
-52
View File
@@ -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);
+5
View File
@@ -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', () => {
+7 -1
View File
@@ -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 {
+2
View File
@@ -25,6 +25,8 @@ export interface ProductDto {
/** 详情长图(bizType=DETAIL 或 detailContent JSON */
detailImageUrls?: string[];
detailContent?: ProductDetailContentDto | null;
/** 是否允许现场取货下单 */
allowOnSitePickup?: boolean;
}
export interface ProductDetailFeatureDto {
+1
View File
@@ -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 {
+5 -1
View File
@@ -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 = {
+2
View File
@@ -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 {
/**
* 调用微信 getwxacodeunlimitscene=推广活动 IDPNG 上传 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);
}