39 Commits

Author SHA1 Message Date
jacy da6a38daa4 merge(dev): fix batch image upload persistence
CI / verify (push) Has been cancelled
2026-08-07 21:13:45 +08:00
jacy c5926bd593 merge(dev_jacy): fix batch image upload persistence 2026-08-07 21:13:42 +08:00
jacy df546ff39f fix(admin): keep all batch-uploaded images when saving packages
Serialize multi-file uploads and reload package list after save so imageUrls persist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 21:13:31 +08:00
jacy d6d4a77473 merge(dev): local API port 3010
CI / verify (push) Has been cancelled
2026-08-07 21:04:06 +08:00
jacy 0518976953 merge(dev_jacy): local API port 3010 2026-08-07 21:03:59 +08:00
jacy 8f83624ffe chore(dev): move local API default port to 3010
Avoid conflict with another local service on 3000; align Vite proxies and docs.
2026-08-07 21:03:23 +08:00
jacy 7c6dae472c merge(dev): persist package imageUrls on approve
CI / verify (push) Has been cancelled
2026-08-07 20:46:39 +08:00
jacy 209db59422 merge(dev_jacy): persist package imageUrls on approve 2026-08-07 20:46:36 +08:00
jacy 21d44026d9 fix(store): persist package imageUrls on audit approve
Ensure multi-image packages are written on approval and always returned as arrays from store detail.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 20:46:26 +08:00
jacy 3b192b0b88 merge(dev): store sortOrder and package gallery swipe
CI / verify (push) Has been cancelled
2026-08-07 19:58:53 +08:00
jacy 1b3a7f3a36 merge(dev_jacy): store sortOrder and package gallery swipe 2026-08-07 19:58:50 +08:00
jacy 34a1897c09 feat(store): add store sortOrder and swipeable package gallery
Enable HQ-controlled store list ordering and horizontal multi-image browsing on package detail.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 19:58:38 +08:00
jacy 36017a19b1 merge(dev): batch image upload for package/env/product
CI / verify (push) Has been cancelled
2026-08-07 19:41:12 +08:00
jacy 9c7550a1c4 merge(dev_jacy): batch image upload for package/env/product 2026-08-07 19:41:10 +08:00
jacy fe1c6c8158 feat(admin): batch upload for package, store env, and product images
Allow multi-select uploads in HQ, partner, and shop for the three image galleries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 19:40:59 +08:00
jacy acc841bb21 merge(dev): v3.4.15 store media edit and package multi-image
CI / verify (push) Has been cancelled
2026-08-07 19:24:20 +08:00
jacy 61a99aeea9 merge(dev_jacy): v3.4.15 store media edit and package multi-image 2026-08-07 19:24:17 +08:00
jacy 76375eff83 feat(v3.4.15): HQ store media edit and package images up to 20
Allow HQ to replace/delete store photos; support multi-image packages with a 20-image cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 19:24:07 +08:00
jacy 713dce52b4 merge(dev): parse XFX multipart track callbacks
CI / verify (push) Has been cancelled
2026-08-07 19:08:15 +08:00
jacy e41bf838d4 merge(dev_jacy): parse XFX multipart track callbacks 2026-08-07 19:08:12 +08:00
jacy 0692bebf07 fix(courier): parse XFX multipart/form-data track callbacks
Xiaofeixia posts multipart form fields; previously mis-read as urlencoded empty payload. Parse multipart text parts and map into order status transitions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 19:08:03 +08:00
jacy b19f875e7b merge(dev): fix XFX callback build types
CI / verify (push) Has been cancelled
2026-08-07 18:58:56 +08:00
jacy 090002b6c0 merge(dev_jacy): fix XFX callback build types 2026-08-07 18:58:54 +08:00
jacy 8726cf14e3 fix(courier): cast IncomingMessage when skipping XFX body parsers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:58:45 +08:00
jacy 7298b1a5d5 merge(dev): log XFX callback rawBody to third-party logs
CI / verify (push) Has been cancelled
2026-08-07 18:57:43 +08:00
jacy 1401f1dcd2 merge(dev_jacy): log XFX callback rawBody to third-party logs 2026-08-07 18:57:41 +08:00
jacy 155426b669 fix(courier): log XFX track callback rawBody and Content-Type
Capture raw request for courier track callbacks regardless of Content-Type and persist contentType/rawBody/query into log_third_party for empty-body diagnosis.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 18:57:31 +08:00
jacy b4d6f2bd91 merge(dev): v3.4.14 test whitelist and SUPER_ADMIN fix
CI / verify (push) Has been cancelled
2026-08-07 15:46:47 +08:00
jacy 029c17347e merge(dev_jacy): v3.4.14 test whitelist and SUPER_ADMIN fix 2026-08-07 15:46:40 +08:00
jacy b626db5d84 feat(ops): add global test whitelist and exclude test accounts from settlement
Unify product/store visibility on HQ whitelist, mark isTest snapshots, and fix SUPER_ADMIN access for the new module.
2026-08-07 15:46:23 +08:00
jacy d98ef6b808 merge(dev): v3.4.15 admin track and store list
CI / verify (push) Has been cancelled
2026-08-07 13:26:05 +08:00
jacy d13a6ee3cd merge(dev_jacy): v3.4.15 admin track and store list 2026-08-07 13:26:03 +08:00
jacy 10fa361983 feat(v3.4.15): admin delivery track drawer and mini-user store list polish
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 13:25:55 +08:00
jacy 1a615ffe93 merge(dev): harden iOS WeChat shop scan
CI / verify (push) Has been cancelled
2026-08-07 13:08:22 +08:00
jacy 0269802e59 merge(dev_jacy): harden iOS WeChat shop scan 2026-08-07 13:08:20 +08:00
jacy e4e9eb2169 fix(h5-shop): harden iOS WeChat scan after login with hard nav and recover UI
Root cause is JSSDK entry-URL mismatch after SPA post-OAuth, not camera permission. Hard-navigate on iOS, keep OAuth query in sign URL, skip redundant bind OAuth, and prompt refresh/re-auth on failure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 13:08:13 +08:00
jacy b5fb9a0bb2 merge(dev): fix iOS shop scan JSSDK entry-URL signing
CI / verify (push) Has been cancelled
2026-08-07 12:54:01 +08:00
jacy 9d800a4cbd merge(dev_jacy): fix iOS shop scan JSSDK entry-URL signing 2026-08-07 12:53:59 +08:00
jacy 2a9493165b fix(weixin-sdk): restore iOS JSSDK entry-URL signing for shop scan
Phone re-login + OAuth on iOS left SPA on a different path than the WebView entry URL, so scanQRCode failed until a full reopen. Sign with the document entry URL again and warm up after auth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 12:53:52 +08:00
102 changed files with 3835 additions and 968 deletions
+3
View File
@@ -39,6 +39,9 @@ src/
## UI 约束
- 使用 shared-ui CSS 变量与组件,勿各端自造设计 token
- **mini-user 微信 `openType` Button**:祖先禁止 `e.stopPropagation()`Taro→`catchtap`,选头像/手机号等会静默失效);遮罩与 sheet 拆开绑关闭。见 `.cursor/rules/mini-user-weapp-opentype.mdc`
- C 端订单 **5 Tab**(含 pending_ship
- 门店列表仅 `OPEN` 状态
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
@@ -0,0 +1,46 @@
---
description: 微信小程序 open-type 按钮踩坑 — 禁止祖先 catchtap / stopPropagation
globs: apps/mini-user/**/*.{tsx,ts,css,scss}
alwaysApply: false
---
# mini-user · 微信 open-type 硬规则
## 禁止(必踩坑)
**现象**`Button openType="chooseAvatar" | getPhoneNumber | getUserInfo | share | contact"` 点击无反应、无回调。
**根因**:祖先节点上的 `onClick={(e) => e.stopPropagation()}` 在 Taro 微信端会编译成 **`catchtap`**,拦截子级 `button` 的原生 open-type 能力。
```tsx
// ❌ 弹层内容上 stopPropagation — 内部 chooseAvatar 会失效
<View className="mask" onClick={close}>
<View className="sheet" onClick={(e) => e.stopPropagation()}>
<Button openType="chooseAvatar" onChooseAvatar={...}>选头像</Button>
</View>
</View>
```
## 正确写法
遮罩与内容拆开:只在 **backdrop** 上关弹层,**sheet 不要**绑 stopPropagation / catchtap。
```tsx
// ✅
<View className="mask">
<View className="backdrop" onClick={close} />
<View className="sheet">
<Button openType="chooseAvatar" plain hoverClass="none" onChooseAvatar={...}>
...
</Button>
</View>
</View>
```
## 附加
- `Button` 内 `Image` / 文案加 `pointer-events: none`(或父级 `> * { pointer-events: none }`),避免抢触摸
- 同类能力:`getPhoneNumber`、`contact`、`share` 同样忌祖先 `catchtap`
- 详情见知识库「C 端 · 踩坑 · chooseAvatar」
参照实现:`apps/mini-user/src/pages/mine/index.tsx` 资料弹层。
@@ -13,7 +13,7 @@
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
Vite 代理:`/api``localhost:3000`
Vite 代理:`/api``localhost:3010`(可用 `VITE_API_TARGET` 覆盖)
## 共享包
+5 -2
View File
@@ -28,7 +28,7 @@ pnpm db:generate && pnpm db:validate
cd server/dukang-api && npx prisma db push && pnpm prisma:seed
# 开发(分终端)
pnpm dev:api # http://localhost:3000/api/v1
pnpm dev:api # http://localhost:3010/api/v1
pnpm dev:user # :5173
pnpm dev:shop # :5174
pnpm dev:partner # :5175
@@ -137,11 +137,14 @@ C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成
```
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再调扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」。
**微信小程序 open-type**`chooseAvatar` 等 Button 的祖先禁止 `stopPropagation`(会编成 catchtap);见知识库「C 端 · 踩坑」、`.cursor/rules/mini-user-weapp-opentype.mdc`
## 环境与发版
| 环境 | 分支 | 目录 | 端口 | 域名 |
|------|------|------|------|------|
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3000` | — |
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3010` | — |
| **staging 测试** | `dev` | `/opt/dukang-staging` | 81908194 | `*-test.dukanghaoke.com` |
| **production 生产** | `main` | `/opt/dukang` | 80908094 | `*.dukanghaoke.com` |
+1
View File
@@ -22,6 +22,7 @@ V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。
- 禁止 import `server/` 或另一个 `apps/*` 的源码
- UI 共享组件优先 `@dukang/shared-ui`
- C 端订单列表 **3 Tab**`待付款 | 已付款 | 已完成`Tab key: `pending_pay` / `paid` / `completed`
- **mini-user 微信 open-type**:含 `chooseAvatar` / `getPhoneNumber` 等的 `Button`,祖先禁止 `stopPropagation`(会编成 `catchtap` 导致点击无反应);见知识库 C 端踩坑、`.cursor/rules/mini-user-weapp-opentype.mdc`
## 新页面 workflow
+2
View File
@@ -46,6 +46,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
import WechatBindingsPage from './pages/WechatBindingsPage';
import HqPermissionsPage from './pages/HqPermissionsPage';
import SystemSettingsPage from './pages/SystemSettingsPage';
import TestWhitelistPage from './pages/TestWhitelistPage';
import WecomBotsPage from './pages/WecomBotsPage';
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
import WecomBotLogsPage from './pages/WecomBotLogsPage';
@@ -136,6 +137,7 @@ export default function App() {
<Route path="/deliveries" element={<DeliveriesPage />} />
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
<Route path="/system-settings" element={<SystemSettingsPage />} />
<Route path="/hq-accounts" element={<HqAccountsPage />} />
</Route>
@@ -2,13 +2,22 @@ import { useEffect, useState } from 'react';
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
import { DownOutlined, UpOutlined } from '@ant-design/icons';
import type { StorePackageItemDto } from '@dukang/shared-types';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT, normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { request } from '../lib/api';
import OssUpload from './OssUpload';
import PackageImagesUpload from './PackageImagesUpload';
type PackageRow = StorePackageItemDto;
function emptyRow(index = 0): PackageRow {
return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', imageUrl: '', sortOrder: index };
return {
name: '',
price: '0',
dishes: '',
usableTime: '',
otherNotes: '',
imageUrl: '',
imageUrls: [],
sortOrder: index,
};
}
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
@@ -23,7 +32,16 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
.then((data) => {
setItems(
data.live?.length
? data.live.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i }))
? data.live.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
})
: [emptyRow()],
);
})
@@ -74,16 +92,20 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
async function save() {
const filled = items
.map((item, index) => ({
.map((item, index) => {
const imageUrls = normalizeStorePackageImageUrls(item);
return {
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || null,
otherNotes: item.otherNotes?.trim() || null,
imageUrl: item.imageUrl?.trim() || null,
imageUrl: imageUrls[0] ?? null,
imageUrls,
sortOrder: index,
}))
.filter((item) => item.name || item.dishes || item.price);
};
})
.filter((item) => item.name || item.dishes || item.price || item.imageUrls.length > 0);
for (let i = 0; i < filled.length; i++) {
const item = filled[i];
@@ -100,17 +122,35 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
message.warning(`${i + 1} 条套餐价格须为非负数字`);
return;
}
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
message.warning(`${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`);
return;
}
}
setSaving(true);
try {
await request(`/admin/stores/${storeId}/packages`, {
const data = await request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`, {
method: 'PUT',
body: JSON.stringify({
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
}),
});
message.success('套餐已保存并生效');
setItems(
data.live?.length
? data.live.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
})
: [emptyRow()],
);
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
@@ -215,11 +255,14 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
</Form.Item>
<Form.Item label="套餐图片" style={{ marginBottom: 12 }}>
<OssUpload
bizType="STORE_PACKAGE"
mediaType="IMAGE"
value={item.imageUrl || undefined}
onChange={(url) => updateAt(index, { imageUrl: url })}
<PackageImagesUpload
value={normalizeStorePackageImageUrls(item)}
onChange={(imageUrls) =>
updateAt(index, {
imageUrls,
imageUrl: imageUrls[0] ?? '',
})
}
/>
</Form.Item>
@@ -242,7 +285,7 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
) : null}
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
C
</Typography.Paragraph>
<Button type="primary" loading={saving} onClick={() => void save()}>
@@ -1,6 +1,5 @@
import { Button, Form, Space, Typography } from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import OssUpload from './OssUpload';
import { Typography } from 'antd';
import MultiImageUpload from './MultiImageUpload';
type Props = {
name?: string;
@@ -8,42 +7,35 @@ type Props = {
bizType?: string;
/** 最多可添加张数;不传则不限制 */
maxCount?: number;
/** Form.Item 注入 */
value?: string[];
onChange?: (urls: string[]) => void;
};
/**
* 商品详情/轮播等多图列表。
* 可直接包在 Form.Item 下(value/onChange),也可用 Form.List 的 name 外层再包 Form.Item。
*/
export default function DetailImageUrlList({
name = 'detailImageUrls',
label,
bizType = 'DETAIL',
maxCount,
value,
onChange,
}: Props) {
return (
<>
{maxCount != null && (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{maxCount} {label}
{label}{maxCount != null ? `,最多 ${maxCount}` : ''}
</Typography.Text>
)}
<Form.List name={name}>
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
<OssUpload bizType={bizType} mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
)}
</Space>
))}
{(!maxCount || fields.length < maxCount) && (
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
{label}
</Button>
)}
</>
)}
</Form.List>
<MultiImageUpload
bizType={bizType}
mediaType="IMAGE"
maxCount={maxCount}
value={value}
onChange={onChange}
tip={`支持一次选择多张${label}`}
/>
</>
);
}
@@ -0,0 +1,184 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Image, Space, Typography, Upload, message } from 'antd';
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
type Props = {
value?: string[];
onChange?: (urls: string[]) => void;
bizType: string;
mediaType?: OssMediaType;
/** 最多张数;不传则不限制 */
maxCount?: number;
tip?: string;
accept?: string;
};
function normalizeUrls(value?: string[]) {
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
}
/**
* 多图批量上传(一次可选多张),用于套餐图 / 环境照 / 商品详情图等。
* Form.Item 直接绑定 string[]。
*/
export default function MultiImageUpload({
value,
onChange,
bizType,
mediaType = 'IMAGE',
maxCount,
tip,
accept = 'image/*',
}: Props) {
const urls = normalizeUrls(value);
const urlsRef = useRef(urls);
const onChangeRef = useRef(onChange);
const [uploading, setUploading] = useState(false);
const batchBuf = useRef<File[]>([]);
const batchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const uploadChain = useRef(Promise.resolve());
useEffect(() => {
urlsRef.current = urls;
}, [urls]);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
return () => {
if (batchTimer.current) clearTimeout(batchTimer.current);
};
}, []);
const remaining = maxCount != null ? Math.max(0, maxCount - urls.length) : Number.POSITIVE_INFINITY;
const canAdd = remaining > 0;
async function uploadBatch(files: File[]) {
const current = urlsRef.current;
const room = maxCount != null ? Math.max(0, maxCount - current.length) : files.length;
const picked = files.slice(0, room);
if (!picked.length) {
message.warning(maxCount != null ? `最多 ${maxCount}` : '无法上传');
return;
}
if (files.length > picked.length) {
message.warning(`已达上限,仅上传前 ${picked.length}`);
}
setUploading(true);
const appended: string[] = [];
let fail = 0;
try {
for (const file of picked) {
try {
const result = await uploadFileToOss(file, { bizType, mediaType });
appended.push(result.url);
} catch {
fail += 1;
}
}
if (appended.length) {
// 始终基于最新列表追加,避免并行上传互相覆盖
const next = [...urlsRef.current, ...appended];
urlsRef.current = next;
onChangeRef.current?.(next);
message.success(`成功上传 ${appended.length}${fail ? `,失败 ${fail}` : ''}`);
} else if (fail) {
message.error('上传失败');
}
} finally {
setUploading(false);
}
}
function enqueueUploadBatch(files: File[]) {
uploadChain.current = uploadChain.current
.then(() => uploadBatch(files))
.catch(() => undefined);
}
function flushBatch() {
if (batchTimer.current) {
clearTimeout(batchTimer.current);
batchTimer.current = null;
}
if (!batchBuf.current.length) return;
const files = [...batchBuf.current];
batchBuf.current = [];
enqueueUploadBatch(files);
}
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
batchBuf.current.push(file as File);
// 多选时 beforeUpload 可能逐文件触发;短防抖合并成一次批量
if (batchTimer.current) clearTimeout(batchTimer.current);
batchTimer.current = setTimeout(() => {
flushBatch();
}, 80);
return false;
};
function removeAt(index: number) {
const next = urlsRef.current.filter((_, i) => i !== index);
urlsRef.current = next;
onChangeRef.current?.(next);
}
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Typography.Text type="secondary">
{tip ??
(maxCount != null
? `最多 ${maxCount} 张,支持一次选择多张批量上传`
: '支持一次选择多张批量上传')}
{maxCount != null ? `(已选 ${urls.length}/${maxCount}` : urls.length ? `(已选 ${urls.length}` : ''}
</Typography.Text>
{urls.length > 0 ? (
<Image.PreviewGroup>
<Space wrap size={12}>
{urls.map((url, index) => (
<div key={`${url}-${index}`} style={{ position: 'relative', width: 96 }}>
<Image
src={url}
width={96}
height={96}
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
/>
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => removeAt(index)}
style={{
position: 'absolute',
top: 0,
right: 0,
background: 'rgba(255,255,255,0.85)',
}}
/>
</div>
))}
</Space>
</Image.PreviewGroup>
) : null}
<Upload
accept={accept}
multiple
showUploadList={false}
beforeUpload={beforeUpload}
disabled={uploading || !canAdd}
>
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canAdd}>
{canAdd ? '批量上传图片' : '已达上限'}
</Button>
</Upload>
</Space>
);
}
@@ -0,0 +1,185 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Empty,
Image,
Space,
Spin,
Timeline,
Typography,
message,
} from 'antd';
import type { OrderTrackDto, OrderTrackNodeDto } from '@dukang/shared-types';
import { isXfxProviderCode } from '@dukang/shared-types';
import { request } from '../lib/api';
import { DELIVERY_PROVIDER_LABELS, fmtTime } from '../lib/constants';
type Props = {
open: boolean;
orderId: string | null;
orderNo?: string | null;
orderStatus?: string | null;
onClose: () => void;
};
function providerLabel(provider?: string | null, company?: string | null) {
if (company) return company;
if (!provider) return '—';
if (DELIVERY_PROVIDER_LABELS[provider]) return DELIVERY_PROVIDER_LABELS[provider];
if (isXfxProviderCode(provider)) return '小飞侠';
return provider;
}
function sortOldestFirst(nodes: OrderTrackNodeDto[]) {
return [...nodes].sort((a, b) => {
const ta = new Date(a.createTime).getTime();
const tb = new Date(b.createTime).getTime();
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
if (Number.isNaN(ta)) return 1;
if (Number.isNaN(tb)) return -1;
return ta - tb;
});
}
export default function OrderTrackDrawer({
open,
orderId,
orderNo,
orderStatus,
onClose,
}: Props) {
const [loading, setLoading] = useState(false);
const [track, setTrack] = useState<OrderTrackDto | null>(null);
const load = useCallback(async () => {
if (!orderId) return;
setLoading(true);
try {
const data = await request<OrderTrackDto>(`/admin/orders/${orderId}/track`);
setTrack(data);
} catch (e) {
setTrack(null);
message.error(e instanceof Error ? e.message : '加载路由失败');
} finally {
setLoading(false);
}
}, [orderId]);
useEffect(() => {
if (!open || !orderId) {
setTrack(null);
return;
}
void load();
}, [open, orderId, load]);
const nodes = useMemo(
() => (track?.nodes?.length ? sortOldestFirst(track.nodes) : []),
[track?.nodes],
);
const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
orderStatus || '',
);
return (
<Drawer
title="物流路由"
width={520}
open={open}
onClose={onClose}
extra={
<Button onClick={() => void load()} loading={loading} disabled={!orderId}>
</Button>
}
>
<Spin spinning={loading}>
{(orderNo || track?.trackingNo || track?.provider) && (
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
{orderNo ? <Descriptions.Item label="订单号">{orderNo}</Descriptions.Item> : null}
<Descriptions.Item label="配送方式">
{providerLabel(track?.provider, track?.logisticsCompany)}
</Descriptions.Item>
<Descriptions.Item label="运单号">{track?.trackingNo || '—'}</Descriptions.Item>
{track?.manualQueryUrl ? (
<Descriptions.Item label="物流查询">
<a href={track.manualQueryUrl} target="_blank" rel="noreferrer">
</a>
</Descriptions.Item>
) : null}
{track?.estimatedArrival?.arriveTime ? (
<Descriptions.Item label="预计送达">
{fmtTime(track.estimatedArrival.arriveTime)}
{track.estimatedArrival.siteName
? `${track.estimatedArrival.siteName}`
: ''}
</Descriptions.Item>
) : null}
</Descriptions>
)}
{track?.signPhotoUrls && track.signPhotoUrls.length > 0 ? (
<div style={{ marginBottom: 16 }}>
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
<Image.PreviewGroup>
<Space wrap>
{track.signPhotoUrls.map((url) => (
<Image
key={url}
src={url}
width={96}
height={96}
style={{ objectFit: 'cover', borderRadius: 6 }}
/>
))}
</Space>
</Image.PreviewGroup>
</div>
) : null}
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
{nodes.length > 0 ? (
<Timeline
items={nodes.map((node, index) => {
const isLatest = index === nodes.length - 1;
return {
color: isLatest ? (timelineComplete ? 'green' : 'blue') : 'gray',
children: (
<div>
{node.statusName ? (
<Typography.Text strong style={{ display: 'block' }}>
{node.statusName}
</Typography.Text>
) : null}
<Typography.Text style={{ display: 'block', whiteSpace: 'pre-wrap' }}>
{node.trackInfo || '—'}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{fmtTime(node.createTime)}
</Typography.Text>
</div>
),
};
})}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
track?.manualQueryUrl
? '暂无实时路由节点,可使用上方物流查询链接'
: '暂无路由信息,请稍后刷新'
}
/>
)}
</Spin>
</Drawer>
);
}
@@ -0,0 +1,21 @@
import { STORE_PACKAGE_IMAGE_MAX_COUNT } from '@dukang/shared-types';
import MultiImageUpload from './MultiImageUpload';
type Props = {
value?: string[];
onChange?: (urls: string[]) => void;
};
/** 套餐多图:批量上传,最多 STORE_PACKAGE_IMAGE_MAX_COUNT 张 */
export default function PackageImagesUpload({ value, onChange }: Props) {
return (
<MultiImageUpload
bizType="STORE_PACKAGE"
mediaType="IMAGE"
maxCount={STORE_PACKAGE_IMAGE_MAX_COUNT}
value={value}
onChange={onChange}
tip={`套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量选择上传,可逐张删除`}
/>
);
}
@@ -146,6 +146,7 @@ const MENU_ITEMS: MenuProps['items'] = [
],
},
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
];
@@ -215,6 +216,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/logs/third-party': 'logs',
'/logs/domain-events': 'logs',
'/hq-permissions': 'hq_permissions',
'/test-whitelist': 'test_whitelist',
'/system-settings': 'system_settings_any',
'/hq-accounts': 'hq_accounts',
};
+2
View File
@@ -167,6 +167,7 @@ export type AdminUserRow = {
sourceLabel: string | null;
createdAt: string;
orderCount: number;
isTest?: boolean;
};
export type AdminOrderItem = {
@@ -199,6 +200,7 @@ export type AdminOrderRow = {
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
isTest?: boolean;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: {
provider: string;
+1
View File
@@ -27,6 +27,7 @@ export type StoreCreateForm = {
bankAccountNo: string;
bankBranch: string;
settlementRate?: number;
sortOrder?: number;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
};
+21 -5
View File
@@ -59,6 +59,7 @@ type Row = {
accountCount: number;
subAccounts?: SubRow[];
createdAt: string;
isTest?: boolean;
};
type PartnerDetail = Row & {
@@ -124,14 +125,15 @@ export default function CityPartnersPage() {
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [subEditForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partners',
() => {
const qs = new URLSearchParams();
if (filters.companyName) qs.set('companyName', filters.companyName);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.companyName) qs.set('companyName', String(filters.companyName));
if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.cityId) qs.set('cityId', String(filters.cityId));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
@@ -270,7 +272,18 @@ export default function CityPartnersPage() {
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
},
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
{
title: '主账号姓名',
dataIndex: 'name',
width: 120,
ellipsis: true,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{ title: '登录手机', dataIndex: 'phone', width: 120 },
{
title: '管辖',
@@ -387,6 +400,9 @@ export default function CityPartnersPage() {
<Form.Item name="phone" label="手机">
<Input allowClear placeholder="登录手机" />
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
+48 -3
View File
@@ -3,13 +3,26 @@ import {
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import OrderTrackDrawer from '../components/OrderTrackDrawer';
import { request } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string; provider: string; trackingNo: string | null; providerOrderNo: string | null; updatedAt: string;
order?: { orderNo: string; status: string; receiverName: string; receiverPhone: string; deliveryType: string };
id: string;
orderId: string;
provider: string;
trackingNo: string | null;
providerOrderNo: string | null;
updatedAt: string;
order?: {
id: string;
orderNo: string;
status: string;
receiverName: string;
receiverPhone: string;
deliveryType: string;
};
};
export default function DeliveriesPage() {
@@ -29,6 +42,22 @@ export default function DeliveriesPage() {
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [trackOpen, setTrackOpen] = useState(false);
const [trackOrderId, setTrackOrderId] = useState<string | null>(null);
const [trackOrderNo, setTrackOrderNo] = useState<string | null>(null);
const [trackOrderStatus, setTrackOrderStatus] = useState<string | null>(null);
function openTrack(row: Row) {
const orderId = row.orderId || row.order?.id;
if (!orderId) {
message.warning('缺少关联订单,无法查询路由');
return;
}
setTrackOrderId(orderId);
setTrackOrderNo(row.order?.orderNo ?? null);
setTrackOrderStatus(row.order?.status ?? null);
setTrackOpen(true);
}
const columns: ColumnsType<Row> = [
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
@@ -39,14 +68,17 @@ export default function DeliveriesPage() {
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
title: '操作', width: 140,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => openTrack(row)}></Button>
<Button type="link" size="small" onClick={async () => {
const d = await request<Row>(`/admin/deliveries/${row.id}`);
setDetail(d);
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
setDrawerOpen(true);
}}></Button>
</Space>
),
},
];
@@ -87,9 +119,22 @@ export default function DeliveriesPage() {
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
</Form>
<Button
style={{ marginTop: 8 }}
onClick={() => openTrack(detail)}
>
</Button>
</>
)}
</Drawer>
<OrderTrackDrawer
open={trackOpen}
orderId={trackOrderId}
orderNo={trackOrderNo}
orderStatus={trackOrderStatus}
onClose={() => setTrackOpen(false)}
/>
</div>
);
}
+40 -2
View File
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import {
Alert,
Button,
Checkbox,
Collapse,
Descriptions,
Drawer,
@@ -30,6 +31,7 @@ import {
} from '../lib/constants';
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
import ProxyOrderModal from '../components/ProxyOrderModal';
import OrderTrackDrawer from '../components/OrderTrackDrawer';
type ShipDefaults = {
provider: string;
@@ -186,6 +188,7 @@ export default function OrdersPage() {
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [proxyOpen, setProxyOpen] = useState(false);
const [trackOpen, setTrackOpen] = useState(false);
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
@@ -223,6 +226,7 @@ export default function OrdersPage() {
if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
if (values.excludeTest) qs.set('excludeTest', 'true');
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res);
} finally {
@@ -430,7 +434,17 @@ export default function OrdersPage() {
}
const columns: ColumnsType<AdminOrderRow> = [
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
{
title: '订单号',
dataIndex: 'orderNo',
width: 200,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{
title: '城市',
width: 90,
@@ -598,6 +612,9 @@ export default function OrdersPage() {
options={[{ value: true, label: '仅待确认大单' }]}
/>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
@@ -859,7 +876,20 @@ export default function OrdersPage() {
</Descriptions>
{detail.delivery && (
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
<Descriptions
column={1}
bordered
size="small"
title={
<Space>
<span></span>
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setTrackOpen(true)}>
</Button>
</Space>
}
style={{ marginTop: 16 }}
>
<Descriptions.Item label="快递公司">
{detail.delivery.logisticsCompany ||
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
@@ -1232,6 +1262,14 @@ export default function OrdersPage() {
void openDetail(order.id);
}}
/>
<OrderTrackDrawer
open={trackOpen}
orderId={detail?.id ?? null}
orderNo={detail?.orderNo}
orderStatus={detail?.status}
onClose={() => setTrackOpen(false)}
/>
</div>
);
}
@@ -34,7 +34,7 @@ function mapToForm(d: Record<string, unknown>) {
const row = d as Row;
return {
...row,
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [''],
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [],
features: row.features?.length
? row.features
: [{ icon: 'star', title: '', desc: '' }],
@@ -68,12 +68,14 @@ function buildPayload(v: TemplateFormValues) {
function TemplateContentFields() {
return (
<>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text type="secondary"></Typography.Text>
<Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
<DetailImageUrlList
label="详情图"
bizType="DETAIL_TEMPLATE"
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
/>
</Form.Item>
<Divider />
<Form.Item name="storyTitle" label="故事标题">
<Input placeholder="如:千年杜康 · 唯有此处" />
@@ -228,7 +230,7 @@ export default function ProductDetailTemplatesPage() {
}} width={640}>
<Form form={createForm} layout="vertical" initialValues={{
status: 'ACTIVE', sortOrder: 0,
detailImageUrls: [''],
detailImageUrls: [],
features: [{ icon: 'water_drop', title: '', desc: '' }],
}}>
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
+21 -101
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react';
import { useMemo, useState } from 'react';
import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
Switch, Table, Tabs, Tag, Typography, message,
@@ -9,6 +9,7 @@ import { request } from '../lib/api';
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
import MultiImageUpload from '../components/MultiImageUpload';
import DetailImageUrlList from '../components/DetailImageUrlList';
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
import type { FormInstance } from 'antd/es/form';
@@ -67,21 +68,14 @@ type ProductFormValues = {
features?: Array<{ icon?: string; title?: string; desc?: string }>;
};
type UserPickRow = {
id: string;
phone?: string | null;
nickname?: string | null;
userNo?: string;
};
function mapDetailToForm(d: Record<string, unknown>) {
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
const row = d as Row;
return {
...d,
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : ['']) as string[],
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : ['']) as string[],
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : []) as string[],
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : []) as string[],
allowOnlinePurchase: row.allowOnlinePurchase !== false,
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
allowOnSitePickup: !!row.allowOnSitePickup,
@@ -112,10 +106,6 @@ function buildProductPayload(v: ProductFormValues) {
features: features.length ? features : undefined,
};
const visibilityPhones = (v.visibilityPhones ?? [])
.map((p) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean);
return {
barcode69: v.barcode69,
name: v.name,
@@ -131,7 +121,6 @@ function buildProductPayload(v: ProductFormValues) {
allowCrossCityDelivery:
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
visibilityPhones,
coverUrl: v.coverUrl,
carouselUrls,
detailImageUrls,
@@ -141,25 +130,13 @@ function buildProductPayload(v: ProductFormValues) {
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
return (
<Form.List name={name}>
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
<OssUpload bizType={bizType} mediaType="IMAGE" />
<Form.Item name={name} style={{ marginBottom: 0 }}>
<MultiImageUpload
bizType={bizType}
mediaType="IMAGE"
tip={`${label}支持一次选择多张批量上传`}
/>
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
)}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
{label}
</Button>
</>
)}
</Form.List>
);
}
@@ -168,11 +145,13 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
<>
<ProductDetailTemplatePicker form={form} aromaType={aromaType} />
<Divider />
<Typography.Text type="secondary">CAROUSEL 10MB</Typography.Text>
<Typography.Text type="secondary">CAROUSEL 10MB</Typography.Text>
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
<Divider />
<Typography.Text type="secondary">DETAIL 10MB</Typography.Text>
<Typography.Text type="secondary">DETAIL 10MB</Typography.Text>
<Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
<DetailImageUrlList label="详情图" bizType="DETAIL" />
</Form.Item>
<Divider />
<Form.Item name="storyTitle" label="故事标题">
<Input placeholder="如:千年杜康 · 唯有此处" />
@@ -213,33 +192,6 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
}
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
const [userSearching, setUserSearching] = useState(false);
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
async function searchUsers(keyword: string) {
const q = keyword.trim();
if (searchTimer.current) clearTimeout(searchTimer.current);
if (!q) {
setUserOptions([]);
return;
}
searchTimer.current = setTimeout(() => {
void (async () => {
setUserSearching(true);
try {
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
} catch {
setUserOptions([]);
} finally {
setUserSearching(false);
}
})();
}, 300);
}
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return (
@@ -248,46 +200,14 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
name="visibilityWhitelistEnabled"
label="可见白名单"
valuePropName="checked"
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试"
extra="开启后仅全局测试白名单内手机号在 C 端可见/可购,用于在线测试"
>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
{enabled ? (
<>
<Form.Item
name="visibilityPhones"
label="白名单手机号"
rules={[{ required: true, message: '请至少添加一个手机号' }]}
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
>
<Select
mode="tags"
tokenSeparators={[',', ' ', '', ';', '']}
placeholder="输入手机号后回车"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="从用户库添加">
<Select
showSearch
filterOption={false}
placeholder="按手机号搜索用户"
loading={userSearching}
options={userOptions.map((u) => ({
value: u.phone!,
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `${u.userNo}` : ''}`,
}))}
onSearch={searchUsers}
onSelect={(phone: string) => {
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
if (!cur.includes(phone)) {
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
}
}}
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
/>
</Form.Item>
</>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
</Typography.Text>
) : null}
</>
);
@@ -419,8 +339,8 @@ export default function ProductsPage() {
title: '白名单',
dataIndex: 'visibilityWhitelistEnabled',
width: 90,
render: (v: boolean, row) =>
v ? <Tag color="orange">{row.visibilityPhones?.length ?? 0}</Tag> : <Tag></Tag>,
render: (v: boolean) =>
v ? <Tag color="orange"></Tag> : <Tag></Tag>,
},
{
title: '履约',
@@ -539,7 +459,7 @@ export default function ProductsPage() {
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
visibilityWhitelistEnabled: false, visibilityPhones: [],
carouselUrls: [''], detailImageUrls: [''],
carouselUrls: [], detailImageUrls: [],
features: [{ icon: 'water_drop', title: '', desc: '' }],
}}>
<Tabs items={[
+25 -6
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Button, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
@@ -14,6 +14,7 @@ type Row = {
settleAmount: number;
channel?: RedeemChannel;
createdAt: string;
isTest?: boolean;
user?: { userNo: string; phone: string | null; nickname?: string | null };
store?: { name: string; cityName: string };
coupon?: { couponNo: string };
@@ -26,14 +27,15 @@ function maskPhone(phone: string | null | undefined) {
export default function RedeemRecordsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/redeem-records',
() => {
const qs = new URLSearchParams();
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.channel) qs.set('channel', filters.channel);
if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo));
if (filters.storeId) qs.set('storeId', String(filters.storeId));
if (filters.channel) qs.set('channel', String(filters.channel));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
@@ -53,7 +55,21 @@ export default function RedeemRecordsPage() {
}
const columns: ColumnsType<Row> = [
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
{
title: '核销号',
dataIndex: 'redeemNo',
width: 200,
render: (v, row) => (
<span>
{v}
{row.isTest ? (
<Tag color="orange" style={{ marginLeft: 6 }}>
</Tag>
) : null}
</span>
),
},
{
title: '方式',
dataIndex: 'channel',
@@ -124,6 +140,9 @@ export default function RedeemRecordsPage() {
]}
/>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
+20 -5
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
@@ -15,6 +15,7 @@ type Row = {
name: string;
status: string;
createdAt: string;
isTest?: boolean;
storeCount?: number;
staffCount?: number;
bankAccountName?: string | null;
@@ -30,13 +31,14 @@ type StoreOption = { id: string; name: string };
export default function StoreAccountsPage() {
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-accounts',
() => {
const qs = new URLSearchParams();
if (filters.phone) qs.set('phone', filters.phone);
if (filters.status) qs.set('status', filters.status);
if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.status) qs.set('status', String(filters.status));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
@@ -73,7 +75,17 @@ export default function StoreAccountsPage() {
}
const columns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name', width: 100 },
{
title: '姓名',
dataIndex: 'name',
width: 120,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '绑定门店',
@@ -162,6 +174,9 @@ export default function StoreAccountsPage() {
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table
+104 -266
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Alert,
Button,
Checkbox,
Descriptions,
Drawer,
Form,
@@ -22,11 +23,10 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { FormInstance } from 'antd/es/form';
import { FilePdfOutlined, LinkOutlined, EnvironmentOutlined } from '@ant-design/icons';
import { EnvironmentOutlined } from '@ant-design/icons';
import { request, type Paginated } from '../lib/api';
import {
ADMIN_OPTIONS_PAGE_SIZE,
RESOURCE_BIZ_TYPE_LABELS,
STORE_AUDIT_STATUS_LABELS,
STORE_STATUS_LABELS,
fmtTime,
@@ -40,6 +40,7 @@ import { useAdminList } from '../lib/useAdminList';
import { resolveRegionBinding } from '../lib/china-region';
import ChinaRegionCascader from '../components/ChinaRegionCascader';
import OssUpload from '../components/OssUpload';
import MultiImageUpload from '../components/MultiImageUpload';
import TencentLocPickerModal from '../components/TencentLocPickerModal';
import AdminStorePackagesSection from '../components/AdminStorePackagesSection';
@@ -81,18 +82,6 @@ type StoreMediaItem = {
url?: string | null;
};
function isImageMedia(url: string, mediaType?: string) {
if (mediaType === 'IMAGE') return true;
if (mediaType === 'VIDEO' || mediaType === 'FILE') {
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
}
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
}
function isPdfUrl(url: string) {
return /\.pdf(\?|#|$)/i.test(url);
}
function collectMediaUrls(detail: Record<string, unknown>) {
const media = Array.isArray(detail.media) ? (detail.media as StoreMediaItem[]) : [];
const byType = (bizType: string) =>
@@ -118,195 +107,38 @@ function collectMediaUrls(detail: Record<string, unknown>) {
};
}
function StoreAuditMediaSection({ detail }: { detail: Record<string, unknown> }) {
const { covers, envs, contracts } = collectMediaUrls(detail);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const gallery = [...covers, ...envs].filter((item) => isImageMedia(item.url, item.mediaType));
if (covers.length === 0 && envs.length === 0 && contracts.length === 0) {
function StoreAuditMediaEditor() {
return (
<div style={{ marginBottom: 16 }}>
<Alert
type="warning"
type="info"
showIcon
style={{ marginBottom: 16 }}
message="暂无门头照 / 环境照 / 签约合同,请谨慎审核"
message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照最多 20 张。"
/>
);
}
return (
<div style={{ marginBottom: 16 }}>
<Typography.Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
</Typography.Title>
{(covers.length > 0 || envs.length > 0) && (
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
/
</Typography.Text>
<Image.PreviewGroup>
<Space wrap size={12}>
{gallery.map((item) => (
<div key={item.id} style={{ textAlign: 'center' }}>
<Image
src={item.url}
width={112}
height={84}
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
<Form.Item name="coverUrl" label="门头照">
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
</Form.Item>
<Form.Item
name="envPhotoUrls"
label="环境照片"
extra="建议至少 3 张;支持批量上传,最多 20 张。可逐张删除后保存。"
>
<MultiImageUpload
bizType="STORE_ENV"
mediaType="IMAGE"
maxCount={20}
tip="环境照支持一次选择多张批量上传"
/>
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 4 }}>
{covers.some((c) => c.id === item.id) ? '门头照' : '环境照'}
</div>
</div>
))}
</Space>
</Image.PreviewGroup>
</div>
)}
{contracts.length > 0 && (
<div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
</Typography.Text>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{contracts.map((item, index) => {
const imageLike = isImageMedia(item.url, item.mediaType);
const pdf = isPdfUrl(item.url);
return (
<div
key={item.id || `${item.url}-${index}`}
style={{
display: 'flex',
gap: 12,
alignItems: 'center',
padding: 12,
border: '1px solid #f0f0f0',
borderRadius: 8,
background: '#fafafa',
}}
>
{imageLike ? (
<Image.PreviewGroup>
<Image
src={item.url}
width={96}
height={72}
style={{ objectFit: 'cover', borderRadius: 6 }}
/>
</Image.PreviewGroup>
) : (
<div
style={{
width: 96,
height: 72,
borderRadius: 6,
background: '#fff',
border: '1px dashed #d9d9d9',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#cf1322',
}}
>
<FilePdfOutlined style={{ fontSize: 28 }} />
</div>
)}
<Space direction="vertical" size={4} style={{ flex: 1, minWidth: 0 }}>
<Typography.Text strong>
{RESOURCE_BIZ_TYPE_LABELS.CONTRACT || '合同'}
{contracts.length > 1 ? ` ${index + 1}` : ''}
</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ maxWidth: '100%' }}>
{item.url}
</Typography.Text>
<Space wrap>
{imageLike ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
{pdf ? (
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setPdfUrl(item.url)}>
PDF
</Button>
) : null}
<Button
type="link"
size="small"
icon={<LinkOutlined />}
style={{ padding: 0 }}
href={item.url}
target="_blank"
rel="noreferrer"
>
</Button>
</Space>
</Space>
</div>
);
})}
</Space>
</div>
)}
<Modal
title="合同预览"
open={!!pdfUrl}
onCancel={() => setPdfUrl(null)}
width={900}
footer={[
<Button key="open" href={pdfUrl || undefined} target="_blank" rel="noreferrer">
</Button>,
<Button key="close" type="primary" onClick={() => setPdfUrl(null)}>
</Button>,
]}
destroyOnClose
>
{pdfUrl ? (
<iframe
title="合同 PDF 预览"
src={pdfUrl}
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 8 }}
/>
) : null}
</Modal>
</Form.Item>
<Form.Item name="contractUrl" label="签约合同" style={{ marginTop: 16 }}>
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
</Form.Item>
</div>
);
}
type UserPickRow = { id: string; phone?: string | null; nickname?: string | null; userNo?: string | null };
function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
const [userSearching, setUserSearching] = useState(false);
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
async function searchUsers(keyword: string) {
const q = keyword.trim();
if (searchTimer.current) clearTimeout(searchTimer.current);
if (!q) {
setUserOptions([]);
return;
}
searchTimer.current = setTimeout(() => {
void (async () => {
setUserSearching(true);
try {
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
} catch {
setUserOptions([]);
} finally {
setUserSearching(false);
}
})();
}, 300);
}
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return (
@@ -315,46 +147,14 @@ function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
name="visibilityWhitelistEnabled"
label="可见白名单"
valuePropName="checked"
extra="开启后仅名单内手机号在 C 端可见,用于在线测试"
extra="开启后仅全局测试白名单内手机号在 C 端可见,用于在线测试"
>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
{enabled ? (
<>
<Form.Item
name="visibilityPhones"
label="白名单手机号"
rules={[{ required: true, message: '请至少添加一个手机号' }]}
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
>
<Select
mode="tags"
tokenSeparators={[',', ' ', '', ';', '']}
placeholder="输入手机号后回车"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="从用户库添加">
<Select
showSearch
filterOption={false}
placeholder="按手机号搜索用户"
loading={userSearching}
options={userOptions.map((u) => ({
value: u.phone!,
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `${u.userNo}` : ''}`,
}))}
onSearch={searchUsers}
onSelect={(phone: string) => {
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
if (!cur.includes(phone)) {
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
}
}}
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
/>
</Form.Item>
</>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
</Typography.Text>
) : null}
</>
);
@@ -375,6 +175,7 @@ type StoreRow = {
createdAt: string;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
isTest?: boolean;
cityRef?: { name: string; code: string };
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
account?: {
@@ -391,6 +192,7 @@ type StoreRow = {
closeTime2?: string | null;
avgPrice?: number | null;
settlementRate?: number;
sortOrder?: number;
category?: { id: string; name: string; parentId?: string | null } | null;
};
@@ -424,8 +226,8 @@ export default function StoresPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm<StoreCreateForm>();
const [filters, setFilters] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
const init: Record<string, string | boolean> = {};
if (initialCityId) init.cityId = initialCityId;
if (initialPartnerId) init.partnerId = initialPartnerId;
return init;
@@ -434,12 +236,13 @@ export default function StoresPage() {
'/admin/stores',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.status) qs.set('status', filters.status);
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.name) qs.set('name', String(filters.name));
if (filters.status) qs.set('status', String(filters.status));
if (filters.auditStatus) qs.set('auditStatus', String(filters.auditStatus));
if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.cityId) qs.set('cityId', String(filters.cityId));
if (filters.partnerId) qs.set('partnerId', String(filters.partnerId));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
@@ -576,6 +379,15 @@ export default function StoresPage() {
? String(d.benefitUsageRule)
: '',
coverUrl: d.coverUrl,
envPhotoUrls: (() => {
const { envs } = collectMediaUrls(d);
const urls = envs.map((item) => item.url).filter(Boolean);
return urls;
})(),
contractUrl: (() => {
const { contracts } = collectMediaUrls(d);
return contracts[0]?.url || '';
})(),
province: d.province,
city: d.cityName,
district: d.district,
@@ -597,6 +409,8 @@ export default function StoresPage() {
visibilityPhones: Array.isArray(d.visibilityPhones)
? (d.visibilityPhones as string[])
: [],
isTest: !!d.isTest,
sortOrder: d.sortOrder != null ? Number(d.sortOrder) : 0,
});
setDrawerOpen(true);
}
@@ -614,7 +428,11 @@ export default function StoresPage() {
const payload = {
name: v.name,
phone: v.phone,
coverUrl: v.coverUrl,
coverUrl: v.coverUrl ?? '',
envPhotoUrls: Array.isArray(v.envPhotoUrls)
? v.envPhotoUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
: [],
contractUrl: v.contractUrl ?? '',
intro: v.intro,
benefitUsageRule:
typeof v.benefitUsageRule === 'string' &&
@@ -637,9 +455,8 @@ export default function StoresPage() {
bankAccountNo: v.bankAccountNo ?? null,
bankBranch: v.bankBranch ?? null,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? [])
.map((p) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean),
isTest: !!v.isTest,
sortOrder: v.sortOrder != null ? Number(v.sortOrder) : 0,
...(hasCoords
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
: {}),
@@ -722,8 +539,9 @@ export default function StoresPage() {
function openCreateModal() {
void loadOptions();
createForm.setFieldsValue({
envPhotoUrls: ['', '', ''],
envPhotoUrls: [],
settlementRate: 60,
sortOrder: 0,
openTime: '10:00',
closeTime: '22:00',
openTime2: undefined,
@@ -812,10 +630,8 @@ export default function StoresPage() {
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
bankBranch: values.bankBranch.trim(),
settlementRate: Number(values.settlementRate ?? 60) / 100,
sortOrder: values.sortOrder != null ? Number(values.sortOrder) : 0,
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
visibilityPhones: (values.visibilityPhones ?? [])
.map((p: string) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean),
}),
});
message.success('门店已创建');
@@ -839,7 +655,12 @@ export default function StoresPage() {
title: '封面', dataIndex: 'coverUrl', width: 72,
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
},
{ title: '门店名', dataIndex: 'name', width: 140 },
{ title: '门店名', dataIndex: 'name', width: 160, render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
) },
{
title: '分类',
width: 100,
@@ -879,10 +700,11 @@ export default function StoresPage() {
title: '可见',
dataIndex: 'visibilityWhitelistEnabled',
width: 90,
render: (v, row) =>
v ? <Tag color="orange">{row.visibilityPhones?.length ?? 0}</Tag> : <Tag></Tag>,
render: (v) =>
v ? <Tag color="orange"></Tag> : <Tag></Tag>,
},
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
@@ -939,6 +761,9 @@ export default function StoresPage() {
options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item>
<Button
@@ -1159,6 +984,13 @@ export default function StoresPage() {
<Form.Item name="avgPrice" label="人均费用(选填)">
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
</Form.Item>
<Form.Item
name="sortOrder"
label="排序"
extra="数值越小越靠前;同排序时按距离(有定位)或创建时间"
>
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="0" />
</Form.Item>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
<Input type="time" style={{ width: 140 }} />
@@ -1176,6 +1008,14 @@ export default function StoresPage() {
</Form.Item>
</Space>
<StoreVisibilityWhitelistFields form={editForm} />
<Form.Item
name="isTest"
label="测试门店"
valuePropName="checked"
extra="测试门店核销不计入结算账单;联系电话命中全局白名单时会自动标记"
>
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</>
),
},
@@ -1220,7 +1060,8 @@ export default function StoresPage() {
{
key: 'media',
label: '审核材料',
children: <StoreAuditMediaSection detail={detail} />,
forceRender: true,
children: <StoreAuditMediaEditor />,
},
{
key: 'packages',
@@ -1326,6 +1167,9 @@ export default function StoresPage() {
<Form.Item name="phone" label="门店手机号(登录账号)" rules={[{ required: true, message: '请填写门店手机号' }]}>
<Input placeholder="11位手机号" />
</Form.Item>
<Form.Item name="sortOrder" label="排序" extra="数值越小越靠前">
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="0" />
</Form.Item>
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
</Form.Item>
@@ -1410,24 +1254,18 @@ export default function StoresPage() {
<Form.Item name="coverUrl" label="门头照">
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
</Form.Item>
<Typography.Text strong></Typography.Text>
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
3
</Typography.Paragraph>
<Form.List name="envPhotoUrls">
{(fields, { add }) => (
<div style={{ marginTop: 8 }}>
{fields.map((field, index) => (
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`}>
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
<Form.Item
name="envPhotoUrls"
label="环境照片"
extra="选填;支持批量上传,最多 20 张"
>
<MultiImageUpload
bizType="STORE_ENV"
mediaType="IMAGE"
maxCount={20}
tip="环境照支持一次选择多张批量上传"
/>
</Form.Item>
))}
<Button type="dashed" onClick={() => add('')} block>
</Button>
</div>
)}
</Form.List>
<Form.Item name="contractUrl" label="签约合同">
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
</Form.Item>
@@ -0,0 +1,687 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Card,
Checkbox,
Descriptions,
Drawer,
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
type PhoneRow = {
id: string;
phone: string;
note: string | null;
createdByHqId: string | null;
createdAt: string;
updatedAt: string;
};
type AccountType = 'user' | 'store_account' | 'partner' | 'store' | 'order';
type LinkedPayload = {
phone: PhoneRow;
users: Array<{ id: string; userNo: string; phone: string | null; nickname: string | null; isTest: boolean; status: number }>;
storeAccounts: Array<{ id: string; phone: string; name: string; isTest: boolean; status: string }>;
partners: Array<{ id: string; phone: string; name: string; companyName: string | null; isTest: boolean; status: string }>;
stores: Array<{ id: string; name: string; phone: string; isTest: boolean; status: string }>;
};
const ACCOUNT_TYPE_OPTIONS: { value: AccountType; label: string }[] = [
{ value: 'user', label: 'C 端用户' },
{ value: 'store_account', label: '门店账号' },
{ value: 'partner', label: '合伙人' },
{ value: 'store', label: '门店' },
{ value: 'order', label: '订单' },
];
export default function TestWhitelistPage() {
const [mockSms, setMockSms] = useState(false);
const [mockWechat, setMockWechat] = useState(false);
const [mockPay, setMockPay] = useState(false);
const [mockLoading, setMockLoading] = useState(true);
const [mockSaving, setMockSaving] = useState(false);
const [phoneForm] = Form.useForm();
const [phones, setPhones] = useState<Paginated<PhoneRow> | null>(null);
const [phonesLoading, setPhonesLoading] = useState(false);
const [phonePage, setPhonePage] = useState(1);
const [phonePageSize, setPhonePageSize] = useState(20);
const [phoneFilters, setPhoneFilters] = useState<{ phone?: string }>({});
const [addOpen, setAddOpen] = useState(false);
const [addForm] = Form.useForm();
const [editOpen, setEditOpen] = useState(false);
const [editRow, setEditRow] = useState<PhoneRow | null>(null);
const [editForm] = Form.useForm();
const [migrating, setMigrating] = useState(false);
const [accountType, setAccountType] = useState<AccountType>('user');
const [accountPhone, setAccountPhone] = useState('');
const [accounts, setAccounts] = useState<Paginated<Record<string, unknown>> | null>(null);
const [accountsLoading, setAccountsLoading] = useState(false);
const [accountPage, setAccountPage] = useState(1);
const [accountPageSize, setAccountPageSize] = useState(20);
const [linkedOpen, setLinkedOpen] = useState(false);
const [linkedLoading, setLinkedLoading] = useState(false);
const [linked, setLinked] = useState<LinkedPayload | null>(null);
async function loadMockFlags() {
setMockLoading(true);
try {
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
'/admin/test-whitelist/mock-flags',
);
setMockSms(!!cfg.mockSms);
setMockWechat(!!cfg.mockWechat);
setMockPay(!!cfg.mockPay);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载 Mock 配置失败');
} finally {
setMockLoading(false);
}
}
async function saveMockFlags(next: { MOCK_SMS?: boolean; MOCK_WECHAT?: boolean; MOCK_PAY?: boolean }) {
const body: { mockSms?: boolean; mockWechat?: boolean; mockPay?: boolean } = {};
if (next.MOCK_SMS !== undefined) body.mockSms = next.MOCK_SMS;
if (next.MOCK_WECHAT !== undefined) body.mockWechat = next.MOCK_WECHAT;
if (next.MOCK_PAY !== undefined) body.mockPay = next.MOCK_PAY;
setMockSaving(true);
try {
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
'/admin/test-whitelist/mock-flags',
{
method: 'PUT',
body: JSON.stringify(body),
},
);
setMockSms(!!cfg.mockSms);
setMockWechat(!!cfg.mockWechat);
setMockPay(!!cfg.mockPay);
message.success('已保存');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
await loadMockFlags();
} finally {
setMockSaving(false);
}
}
const loadPhones = useCallback(async () => {
setPhonesLoading(true);
try {
const qs = new URLSearchParams({
page: String(phonePage),
pageSize: String(phonePageSize),
});
if (phoneFilters.phone) qs.set('phone', phoneFilters.phone);
const res = await request<Paginated<PhoneRow>>(`/admin/test-whitelist/phones?${qs}`);
setPhones(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载手机号名单失败');
} finally {
setPhonesLoading(false);
}
}, [phonePage, phonePageSize, phoneFilters]);
const loadAccounts = useCallback(async () => {
setAccountsLoading(true);
try {
const qs = new URLSearchParams({
type: accountType,
page: String(accountPage),
pageSize: String(accountPageSize),
});
if (accountPhone.trim()) qs.set('phone', accountPhone.trim());
const res = await request<Paginated<Record<string, unknown>>>(
`/admin/test-whitelist/accounts?${qs}`,
);
setAccounts(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载测试账号失败');
} finally {
setAccountsLoading(false);
}
}, [accountType, accountPage, accountPageSize, accountPhone]);
useEffect(() => {
void loadMockFlags();
}, []);
useEffect(() => {
void loadPhones();
}, [loadPhones]);
useEffect(() => {
void loadAccounts();
}, [loadAccounts]);
async function onAddPhone() {
const v = await addForm.validateFields();
try {
await request('/admin/test-whitelist/phones', {
method: 'POST',
body: JSON.stringify({ phone: v.phone, note: v.note || undefined }),
});
message.success('已添加');
setAddOpen(false);
addForm.resetFields();
setPhonePage(1);
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '添加失败');
}
}
async function onEditPhone() {
if (!editRow) return;
const v = await editForm.validateFields();
try {
await request(`/admin/test-whitelist/phones/${editRow.id}`, {
method: 'PATCH',
body: JSON.stringify({ note: v.note ?? null }),
});
message.success('已更新');
setEditOpen(false);
setEditRow(null);
void loadPhones();
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败');
}
}
async function onDeletePhone(id: string) {
try {
await request(`/admin/test-whitelist/phones/${id}`, { method: 'DELETE' });
message.success('已删除');
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
async function onMigrate() {
setMigrating(true);
try {
const res = await request<{ importedCandidates: number; added: number }>(
'/admin/test-whitelist/migrate-visibility',
{ method: 'POST' },
);
message.success(
`导入完成:候选 ${res.importedCandidates} 个,新增 ${res.added}`,
);
void loadPhones();
void loadAccounts();
} catch (e) {
message.error(e instanceof Error ? e.message : '导入失败');
} finally {
setMigrating(false);
}
}
async function openLinked(row: PhoneRow) {
setLinkedOpen(true);
setLinkedLoading(true);
setLinked(null);
try {
const res = await request<LinkedPayload>(`/admin/test-whitelist/phones/${row.id}/linked`);
setLinked(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载关联失败');
setLinkedOpen(false);
} finally {
setLinkedLoading(false);
}
}
const phoneColumns: ColumnsType<PhoneRow> = [
{ title: '手机号', dataIndex: 'phone', width: 140 },
{
title: '备注',
dataIndex: 'note',
ellipsis: true,
render: (v) => v || '—',
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: fmtTime,
},
{
title: '操作',
width: 220,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openLinked(row)}>
</Button>
<Button
type="link"
size="small"
onClick={() => {
setEditRow(row);
editForm.setFieldsValue({ note: row.note ?? '' });
setEditOpen(true);
}}
>
</Button>
<Popconfirm
title="确认移出白名单?"
description="将同步清除该手机号关联账号的测试标记"
okText="确认"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => void onDeletePhone(row.id)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
function accountColumns(): ColumnsType<Record<string, unknown>> {
if (accountType === 'user') {
return [
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => (v as string) || '—' },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '注册', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'store_account') {
return [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'partner') {
return [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '公司', dataIndex: 'companyName', ellipsis: true, render: (v) => (v as string) || '—' },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
if (accountType === 'store') {
return [
{ title: '门店名', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '城市', dataIndex: 'cityName', width: 90 },
{ title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
return [
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
{ title: '状态', dataIndex: 'status', width: 110 },
{
title: '实付',
dataIndex: 'payAmount',
width: 90,
render: (v) => `¥${v}`,
},
{ title: '收货手机', dataIndex: 'receiverPhone', width: 120 },
{
title: '用户手机',
width: 120,
render: (_, row) =>
(row.user as { phone?: string | null } | undefined)?.phone || '—',
},
{
title: '标记',
dataIndex: 'isTest',
width: 80,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
{ title: '下单', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
];
}
return (
<div>
<Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title>
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Mock =
</Typography.Text>
<Space wrap>
<Checkbox
checked={mockSms}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_SMS: e.target.checked })}
>
</Checkbox>
<Checkbox
checked={mockWechat}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_WECHAT: e.target.checked })}
>
</Checkbox>
<Checkbox
checked={mockPay}
disabled={mockSaving}
onChange={(e) => void saveMockFlags({ MOCK_PAY: e.target.checked })}
>
</Checkbox>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
{MOCK_KEYS.join(' / ')}
</Typography.Text>
</Card>
<Tabs
items={[
{
key: 'phones',
label: '手机号名单',
children: (
<>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }} wrap>
<Form
form={phoneForm}
layout="inline"
onFinish={(v) => {
setPhoneFilters({ phone: v.phone || undefined });
setPhonePage(1);
}}
>
<Form.Item name="phone" label="手机号">
<Input allowClear placeholder="模糊搜索" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Space>
<Button
onClick={() => {
Modal.confirm({
title: '从可见性白名单导入',
content: '将商品/门店旧可见性手机号合并入全局名单(幂等),并同步测试标记。',
okText: '开始导入',
cancelText: '取消',
onOk: () => onMigrate(),
});
}}
loading={migrating}
>
</Button>
<Button type="primary" onClick={() => setAddOpen(true)}>
</Button>
</Space>
</Space>
<Table
rowKey="id"
loading={phonesLoading}
columns={phoneColumns}
dataSource={phones?.items ?? []}
pagination={{
current: phonePage,
pageSize: phonePageSize,
total: phones?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPhonePage(p);
setPhonePageSize(ps);
},
}}
/>
</>
),
},
{
key: 'accounts',
label: '测试账号记录',
children: (
<>
<Space style={{ marginBottom: 16 }} wrap>
<Select
style={{ width: 140 }}
value={accountType}
options={ACCOUNT_TYPE_OPTIONS}
onChange={(v: AccountType) => {
setAccountType(v);
setAccountPage(1);
}}
/>
<Input
allowClear
placeholder="按手机号筛选"
style={{ width: 160 }}
value={accountPhone}
onChange={(e) => setAccountPhone(e.target.value)}
onPressEnter={() => {
setAccountPage(1);
void loadAccounts();
}}
/>
<Button
type="primary"
onClick={() => {
if (accountPage !== 1) setAccountPage(1);
else void loadAccounts();
}}
>
</Button>
</Space>
<Table
rowKey="id"
loading={accountsLoading}
columns={accountColumns()}
dataSource={accounts?.items ?? []}
scroll={{ x: 900 }}
pagination={{
current: accountPage,
pageSize: accountPageSize,
total: accounts?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setAccountPage(p);
setAccountPageSize(ps);
},
}}
/>
</>
),
},
]}
/>
<Modal
title="添加白名单手机号"
open={addOpen}
onCancel={() => setAddOpen(false)}
onOk={() => void onAddPhone()}
destroyOnClose
>
<Form form={addForm} layout="vertical">
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' },
]}
>
<Input placeholder="1xxxxxxxxxx" maxLength={11} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={256} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
title={`编辑备注 · ${editRow?.phone ?? ''}`}
open={editOpen}
onCancel={() => {
setEditOpen(false);
setEditRow(null);
}}
onOk={() => void onEditPhone()}
destroyOnClose
>
<Form form={editForm} layout="vertical">
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={256} showCount />
</Form.Item>
</Form>
</Modal>
<Drawer
title={linked ? `关联账号 · ${linked.phone.phone}` : '关联账号'}
open={linkedOpen}
onClose={() => setLinkedOpen(false)}
width={560}
destroyOnClose
>
{linkedLoading ? (
<Typography.Text type="secondary"></Typography.Text>
) : linked ? (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label="手机号">{linked.phone.phone}</Descriptions.Item>
<Descriptions.Item label="备注">{linked.phone.note || '—'}</Descriptions.Item>
</Descriptions>
<div>
<Typography.Title level={5}>C {linked.users.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.users}
columns={[
{ title: '编号', dataIndex: 'userNo' },
{ title: '昵称', dataIndex: 'nickname', render: (v) => v || '—' },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>{linked.storeAccounts.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.storeAccounts}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>{linked.partners.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.partners}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '公司', dataIndex: 'companyName', render: (v) => v || '—' },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
<div>
<Typography.Title level={5}>{linked.stores.length}</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={linked.stores}
columns={[
{ title: '名称', dataIndex: 'name' },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '测试',
dataIndex: 'isTest',
width: 70,
render: (v) => (v ? <Tag color="orange"></Tag> : '—'),
},
]}
/>
</div>
</Space>
) : null}
</Drawer>
</div>
);
}
+15 -1
View File
@@ -112,6 +112,7 @@ export default function UsersPage() {
if (values.status !== undefined && values.status !== '') {
qs.set('status', String(values.status));
}
if (values.excludeTest) qs.set('excludeTest', 'true');
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
setData(res);
} finally {
@@ -237,7 +238,17 @@ export default function UsersPage() {
];
const columns: ColumnsType<AdminUserRow> = [
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{
title: '用户编号',
dataIndex: 'userNo',
width: 140,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{ title: '昵称', dataIndex: 'nickname', width: 100 },
{
title: '手机',
@@ -362,6 +373,9 @@ export default function UsersPage() {
{ value: 0, label: '停用' },
]} />
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
+3 -1
View File
@@ -1,10 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:3010';
export default defineConfig({
plugins: [react()],
server: {
port: 5175,
proxy: { '/api': 'http://localhost:3000' },
proxy: { '/api': apiTarget },
},
});
@@ -0,0 +1,188 @@
import { useEffect, useRef, useState } from 'react';
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
import { enqueueUpload } from '../lib/upload-lock';
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { toastError, toastSuccess } from '../lib/toast';
type Props = {
value?: string[];
onChange?: (urls: string[]) => void;
bizType: string;
mediaType?: OssMediaType;
maxCount?: number;
disabled?: boolean;
label?: string;
};
function isCancelError(msg: string): boolean {
return /cancel|取消/i.test(msg);
}
function normalizeUrls(value?: string[]) {
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
}
/** 合伙人端多图批量上传(微信相册可多选) */
export default function MultiOssUploadField({
value,
onChange,
bizType,
mediaType = 'IMAGE',
maxCount = 20,
disabled,
label,
}: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const pickingRef = useRef(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const urls = normalizeUrls(value);
const remaining = Math.max(0, maxCount - urls.length);
const inWechat = isWechatEnv();
useEffect(() => {
if (!inWechat) return;
void weixinSdk.init().catch(() => {});
}, [inWechat]);
function showUploadError(text: string) {
setError(text);
toastError(text);
}
async function uploadFiles(files: File[]) {
const current = normalizeUrls(value);
const room = Math.max(0, maxCount - current.length);
const picked = files.slice(0, room);
if (!picked.length) {
showUploadError(`最多 ${maxCount}`);
return;
}
setUploading(true);
setError('');
const appended: string[] = [];
try {
for (const file of picked) {
if (!file.size) continue;
const result = await enqueueUpload(() => uploadFileToOss(file, { bizType, mediaType }));
appended.push(result.url);
}
if (appended.length) {
onChange?.([...current, ...appended]);
toastSuccess(`已上传 ${appended.length}`);
}
} catch (e) {
showUploadError(e instanceof Error ? e.message : '上传失败');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
}
async function pickWechat() {
if (pickingRef.current || uploading || disabled || remaining <= 0) return;
pickingRef.current = true;
setError('');
try {
await weixinSdk.init();
const files = await weixinSdk.chooseImages({
count: Math.min(remaining, 9),
sourceType: ['album', 'camera'],
});
if (!files?.length) return;
await uploadFiles(files);
} catch (e) {
const msg = e instanceof Error ? e.message : '无法打开相册';
if (isCancelError(msg)) return;
const formatted = formatChooseImageFailMessage(msg) || msg;
showUploadError(formatted);
inputRef.current?.click();
} finally {
pickingRef.current = false;
}
}
function removeAt(index: number) {
if (disabled) return;
onChange?.(urls.filter((_, i) => i !== index));
}
return (
<div className="partner-oss-upload">
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
className="partner-oss-upload-input"
disabled={disabled || uploading || remaining <= 0}
onChange={(e) => {
const list = Array.from(e.target.files ?? []);
if (list.length) void uploadFiles(list);
}}
/>
{urls.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
{urls.map((url, index) => (
<div key={`${url}-${index}`} style={{ position: 'relative', width: 88, height: 88 }}>
<img
src={url}
alt=""
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
/>
{!disabled ? (
<button
type="button"
className="partner-packages-remove"
style={{
position: 'absolute',
top: 2,
right: 2,
margin: 0,
padding: '2px 6px',
fontSize: 12,
background: 'rgba(0,0,0,0.55)',
color: '#fff',
borderRadius: 4,
}}
onClick={() => removeAt(index)}
>
</button>
) : null}
</div>
))}
</div>
) : null}
<button
type="button"
className="partner-upload-dashed partner-upload-dashed--compact"
disabled={disabled || uploading || remaining <= 0}
onClick={() => {
if (inWechat) void pickWechat();
else inputRef.current?.click();
}}
>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
{uploading ? 'hourglass_top' : 'add_a_photo'}
</span>
<span className="text-primary" style={{ fontWeight: 500 }}>
{uploading
? '上传中…'
: remaining <= 0
? `已达上限 ${maxCount}`
: label ?? `批量上传(${urls.length}/${maxCount}`}
</span>
</button>
{error ? (
<p className="partner-form-error" role="alert">
{error}
</p>
) : null}
</div>
);
}
@@ -1,8 +1,8 @@
import { useState } from 'react';
import type { PackageFormItem } from '../lib/storePackages';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import { emptyPackage } from '../lib/storePackages';
import OssUploadField from './OssUploadField';
import MultiOssUploadField from './MultiOssUploadField';
type Props = {
items: PackageFormItem[];
@@ -131,12 +131,24 @@ export default function StorePackagesForm({ items, onChange, disabled, embedded
</div>
<div className="partner-field">
<label></label>
<OssUploadField
<label> {STORE_PACKAGE_IMAGE_MAX_COUNT} </label>
<MultiOssUploadField
bizType="STORE_PACKAGE"
value={item.imageUrl || ''}
maxCount={STORE_PACKAGE_IMAGE_MAX_COUNT}
disabled={disabled}
onChange={(url) => updateAt(index, { imageUrl: url })}
value={
Array.isArray(item.imageUrls) && item.imageUrls.length > 0
? item.imageUrls.map((u) => String(u ?? '')).filter((u) => u.trim())
: item.imageUrl
? [String(item.imageUrl)]
: []
}
onChange={(imageUrls) =>
updateAt(index, {
imageUrls,
imageUrl: imageUrls[0] ?? '',
})
}
/>
</div>
+22 -5
View File
@@ -1,5 +1,9 @@
import type { StorePackageItemDto } from '@dukang/shared-types';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import {
STORE_PACKAGE_IMAGE_MAX_COUNT,
STORE_PACKAGE_MAX_COUNT,
normalizeStorePackageImageUrls,
} from '@dukang/shared-types';
export type PackageFormItem = StorePackageItemDto;
@@ -11,24 +15,34 @@ export function emptyPackage(index = 0): PackageFormItem {
usableTime: '',
otherNotes: '',
imageUrl: '',
imageUrls: [],
sortOrder: index,
};
}
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
return raw
.map((item, index) => ({
.map((item, index) => {
const imageUrls = normalizeStorePackageImageUrls(item);
return {
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || '',
otherNotes: item.otherNotes?.trim() || '',
imageUrl: item.imageUrl?.trim() || '',
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: index,
}))
};
})
.filter(
(item) =>
item.name || item.price || item.dishes || item.usableTime || item.otherNotes || item.imageUrl,
item.name ||
item.price ||
item.dishes ||
item.usableTime ||
item.otherNotes ||
item.imageUrls.length > 0,
);
}
@@ -43,6 +57,9 @@ export function validatePackageFormItems(items: PackageFormItem[]): string | nul
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) return `${i + 1} 条套餐价格须为非负数字`;
if (!item.dishes) return `${i + 1} 条套餐菜品不能为空`;
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
return `${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`;
}
}
return null;
}
+7 -55
View File
@@ -5,6 +5,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import ChinaRegionPicker from '../components/ChinaRegionPicker';
import OssUploadField from '../components/OssUploadField';
import MultiOssUploadField from '../components/MultiOssUploadField';
import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
@@ -40,8 +41,6 @@ import {
validateStoreStep3,
patchEnvPhotoAt,
addEnvPhotoSlot,
MIN_ENV_PHOTO_COUNT,
} from '../lib/storeDraft';
import StorePackagesForm from '../components/StorePackagesForm';
@@ -250,22 +249,6 @@ export default function StoreCreatePage() {
function patchEnvPhotoUrl(index: number, url: string) {
setForm((prev) => ({
...prev,
envPhotoUrls: patchEnvPhotoAt(prev.envPhotoUrls, index, url),
}));
setSubmitError('');
}
function bindRegionSelection(codes: string[]) {
const binding = resolveRegionBinding(codes, cities);
@@ -957,46 +940,15 @@ export default function StoreCreatePage() {
<section className="partner-form-card">
<h3 className="headline-md"> <span className="text-primary">*</span></h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}> {MIN_ENV_PHOTO_COUNT} </p>
<div className="partner-upload-grid">
{form.envPhotoUrls.map((url, index) => (
<OssUploadField
key={index}
compact
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}> {MIN_ENV_PHOTO_COUNT} 20 </p>
<MultiOssUploadField
bizType="STORE_ENV"
mediaType="IMAGE"
value={url}
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
maxCount={20}
value={form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)}
onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))}
label={`批量上传(${form.envPhotoUrls.map((u) => u.trim()).filter(Boolean).length}/20`}
/>
))}
</div>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 12 }}
onClick={() =>
setForm((prev) => ({ ...prev, envPhotoUrls: addEnvPhotoSlot(prev.envPhotoUrls) }))
}
>
</button>
</section>
+7 -19
View File
@@ -5,8 +5,9 @@ import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canManagePartnerStore } from '../lib/partnerAccess';
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray, patchEnvPhotoAt, addEnvPhotoSlot } from '../lib/storeDraft';
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray } from '../lib/storeDraft';
import OssUploadField from '../components/OssUploadField';
import MultiOssUploadField from '../components/MultiOssUploadField';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import {
canPartnerOpenStore,
@@ -409,26 +410,13 @@ export default function StoreDetailPage() {
</div>
{canMutate && !readOnly ? (
<>
<div className="partner-upload-grid">
{envPhotoUrls.map((url, index) => (
<OssUploadField
key={index}
compact
<MultiOssUploadField
bizType="STORE_ENV"
mediaType="IMAGE"
value={url}
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
maxCount={20}
value={uniqueEnvUrls(envPhotoUrls)}
onChange={(urls) => setEnvPhotoUrls(urls.length ? urls : [''])}
label={`批量上传环境照(${uniqueEnvUrls(envPhotoUrls).length}/20`}
/>
))}
</div>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 12 }}
onClick={() => setEnvPhotoUrls((prev) => addEnvPhotoSlot(prev))}
>
</button>
<button
type="button"
className="partner-btn-outline"
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import type { StorePackagesResponse } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import StorePackagesForm from '../components/StorePackagesForm';
import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
@@ -30,7 +31,18 @@ export default function StorePackagesPage() {
: data.live?.length
? data.live
: [emptyPackage()];
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
setItems(
base.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
}),
);
setPending(data.pendingRequest ?? null);
})
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'))
+1 -1
View File
@@ -14,7 +14,7 @@ export default defineConfig({
server: {
port: 5175,
proxy: {
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3000',
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
},
},
});
@@ -1,5 +1,8 @@
import { useRef, useState } from 'react';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import {
STORE_PACKAGE_IMAGE_MAX_COUNT,
STORE_PACKAGE_MAX_COUNT,
} from '@dukang/shared-types';
import { emptyPackage, type PackageFormItem } from '../lib/storePackages';
import { uploadFileToOss } from '../lib/upload';
@@ -14,14 +17,32 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
async function pickPackageImage(index: number, file?: File | null) {
if (!file || disabled) return;
setUploadingIndex(index);
async function pickPackageImages(pkgIndex: number, fileList?: FileList | null) {
if (!fileList?.length || disabled) return;
const current = items[pkgIndex];
const existing =
Array.isArray(current.imageUrls) && current.imageUrls.length > 0
? current.imageUrls.map((u) => String(u ?? '')).filter((u) => u.trim())
: current.imageUrl
? [String(current.imageUrl)]
: [];
const room = Math.max(0, STORE_PACKAGE_IMAGE_MAX_COUNT - existing.length);
const files = Array.from(fileList).slice(0, room);
if (!files.length) return;
setUploadingIndex(pkgIndex);
try {
const appended: string[] = [];
for (const file of files) {
const result = await uploadFileToOss(file, 'STORE_PACKAGE');
updateAt(index, { imageUrl: result.url });
appended.push(result.url);
}
const next = [...existing, ...appended];
updateAt(pkgIndex, { imageUrls: next, imageUrl: next[0] ?? '' });
} finally {
setUploadingIndex(null);
const input = fileRefs.current[pkgIndex];
if (input) input.value = '';
}
}
@@ -58,6 +79,12 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
{list.map((item, index) => {
const isCollapsed = !!collapsed[index];
const displayName = item.name.trim() || `套餐 ${index + 1}`;
const filled =
Array.isArray(item.imageUrls) && item.imageUrls.length > 0
? item.imageUrls.map((u) => String(u ?? '')).filter((u) => u.trim())
: item.imageUrl
? [String(item.imageUrl)]
: [];
return (
<section key={index} className={`shop-packages-card${isCollapsed ? ' shop-packages-card--collapsed' : ''}`}>
<div className="shop-packages-card-head">
@@ -83,7 +110,7 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
{!isCollapsed ? (
<>
<label className="shop-packages-field">
<span className="shop-packages-label"> *</span>
<span className="shop-packages-label"></span>
<input
className="shop-packages-input"
placeholder="如:套餐A"
@@ -94,12 +121,12 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
</label>
<label className="shop-packages-field">
<span className="shop-packages-label"> *</span>
<span className="shop-packages-label"></span>
<input
className="shop-packages-input"
type="number"
min={0}
step={0.01}
step="0.01"
placeholder="198"
value={item.price}
disabled={disabled}
@@ -108,10 +135,10 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
</label>
<label className="shop-packages-field">
<span className="shop-packages-label"> *</span>
<span className="shop-packages-label"></span>
<textarea
className="shop-packages-textarea"
rows={3}
className="shop-packages-input"
rows={2}
placeholder="红烧肉、红烧鱼、油焖茄子"
value={item.dishes}
disabled={disabled}
@@ -130,14 +157,35 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
/>
</label>
<label className="shop-packages-field">
<span className="shop-packages-label"></span>
{item.imageUrl ? (
<div className="shop-packages-field">
<span className="shop-packages-label">
{STORE_PACKAGE_IMAGE_MAX_COUNT}
</span>
{filled.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
{filled.map((url, imgIndex) => (
<div key={`${url}-${imgIndex}`} style={{ position: 'relative', width: 88 }}>
<img
src={item.imageUrl}
src={url}
alt=""
style={{ width: '100%', maxHeight: 160, objectFit: 'cover', borderRadius: 8, marginBottom: 8 }}
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
/>
{!disabled ? (
<button
type="button"
className="shop-packages-remove"
style={{ position: 'absolute', top: 2, right: 2, margin: 0 }}
onClick={() => {
const next = filled.filter((_, i) => i !== imgIndex);
updateAt(index, { imageUrls: next, imageUrl: next[0] ?? '' });
}}
>
</button>
) : null}
</div>
))}
</div>
) : null}
<input
ref={(el) => {
@@ -145,20 +193,23 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
}}
type="file"
accept="image/*"
multiple
hidden
disabled={disabled}
onChange={(e) => void pickPackageImage(index, e.target.files?.[0])}
disabled={disabled || filled.length >= STORE_PACKAGE_IMAGE_MAX_COUNT}
onChange={(e) => void pickPackageImages(index, e.target.files)}
/>
<button
type="button"
className="shop-packages-add"
style={{ marginTop: 0 }}
disabled={disabled || uploadingIndex === index}
disabled={disabled || uploadingIndex === index || filled.length >= STORE_PACKAGE_IMAGE_MAX_COUNT}
onClick={() => fileRefs.current[index]?.click()}
>
{uploadingIndex === index ? '上传中…' : item.imageUrl ? '更换图片' : '上传图片'}
{uploadingIndex === index
? '上传中…'
: `批量上传(${filled.length}/${STORE_PACKAGE_IMAGE_MAX_COUNT}`}
</button>
</label>
</div>
<label className="shop-packages-field">
<span className="shop-packages-label"></span>
@@ -2,7 +2,10 @@ type WechatScanAuthModalProps = {
open: boolean;
loading?: boolean;
error?: string;
/** bind=首次绑定;recover=扫码 JSSDK 失败后的恢复引导 */
mode?: 'bind' | 'recover';
onAuthorize: () => void;
onRefresh?: () => void;
onCancel: () => void;
};
@@ -10,28 +13,43 @@ export default function WechatScanAuthModal({
open,
loading,
error,
mode = 'bind',
onAuthorize,
onRefresh,
onCancel,
}: WechatScanAuthModalProps) {
if (!open) return null;
const isRecover = mode === 'recover';
return (
<div className="shop-scan-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="shop-scan-auth-title">
<div className="shop-scan-auth-card">
<div className="shop-scan-auth-icon">
<span className="material-symbols-outlined shop-fill-icon">qr_code_scanner</span>
<span className="material-symbols-outlined shop-fill-icon">
{isRecover ? 'sync_problem' : 'qr_code_scanner'}
</span>
</div>
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title"></h2>
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title">
{isRecover ? '扫码能力未就绪' : '微信授权'}
</h2>
<p className="shop-scan-auth-desc">
使使
{isRecover
? '微信扫码接口校验失败(常见于 iPhone 登录/授权后)。请先刷新页面;仍失败再重新授权微信。'
: '扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。'}
</p>
{error && <p className="shop-scan-auth-error" role="alert">{error}</p>}
<div className="shop-scan-auth-actions">
<button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}>
</button>
{isRecover && onRefresh ? (
<button type="button" className="shop-scan-auth-confirm" onClick={onRefresh} disabled={loading}>
</button>
) : null}
<button type="button" className="shop-scan-auth-confirm" onClick={onAuthorize} disabled={loading}>
{loading ? '跳转授权中…' : '微信授权'}
{loading ? '跳转授权中…' : isRecover ? '重新授权微信' : '微信授权'}
</button>
</div>
</div>
+22
View File
@@ -3,6 +3,9 @@ import { isIosDevice } from '@dukang/weixin-sdk';
/** 扫码前发起 OAuth 时标记,回跳后在首页续扫 */
export const SHOP_PENDING_SCAN_KEY = 'shop_pending_scan';
/** 短信登录后绑定微信等 OAuth 回跳:下次手动扫码加长预热(不自动打开相机) */
export const SHOP_SCAN_WARMUP_KEY = 'shop_scan_warmup';
export function markPendingScanAfterAuth(): void {
try {
sessionStorage.setItem(SHOP_PENDING_SCAN_KEY, '1');
@@ -27,6 +30,25 @@ export function clearPendingScanAfterAuth(): void {
}
}
/** OAuth(含短信登录后绑微信)回跳后,标记下一次扫码需要加长预热 */
export function markScanWarmupAfterAuth(): void {
try {
sessionStorage.setItem(SHOP_SCAN_WARMUP_KEY, '1');
} catch {
/* ignore */
}
}
export function consumeScanWarmupAfterAuth(): boolean {
try {
if (sessionStorage.getItem(SHOP_SCAN_WARMUP_KEY) !== '1') return false;
sessionStorage.removeItem(SHOP_SCAN_WARMUP_KEY);
return true;
} catch {
return false;
}
}
/** OAuth 回跳后延迟再调 scanQRCodeiOS JSSDK 离线校验更慢) */
export function getPostAuthScanDelayMs(): number {
return isIosDevice() ? 1200 : 600;
+28 -5
View File
@@ -1,5 +1,9 @@
import type { StorePackageItemDto } from '@dukang/shared-types';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import {
STORE_PACKAGE_IMAGE_MAX_COUNT,
STORE_PACKAGE_MAX_COUNT,
normalizeStorePackageImageUrls,
} from '@dukang/shared-types';
export type PackageFormItem = StorePackageItemDto;
@@ -11,24 +15,34 @@ export function emptyPackage(index = 0): PackageFormItem {
usableTime: '',
otherNotes: '',
imageUrl: '',
imageUrls: [],
sortOrder: index,
};
}
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
return raw
.map((item, index) => ({
.map((item, index) => {
const imageUrls = normalizeStorePackageImageUrls(item);
return {
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || '',
otherNotes: item.otherNotes?.trim() || '',
imageUrl: item.imageUrl?.trim() || '',
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: index,
}))
};
})
.filter(
(item) =>
item.name || item.price || item.dishes || item.usableTime || item.otherNotes || item.imageUrl,
item.name ||
item.price ||
item.dishes ||
item.usableTime ||
item.otherNotes ||
item.imageUrls.length > 0,
);
}
@@ -43,6 +57,15 @@ export function validatePackageFormItems(items: PackageFormItem[]): string | nul
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) return `${i + 1} 条套餐价格须为非负数字`;
if (!item.dishes) return `${i + 1} 条套餐菜品不能为空`;
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
return `${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`;
}
}
return null;
}
export function formatPackagePrice(price: string | number) {
const n = typeof price === 'number' ? price : Number(price);
if (!Number.isFinite(n)) return String(price);
return n % 1 === 0 ? String(n) : n.toFixed(2);
}
+9 -3
View File
@@ -2,6 +2,7 @@ import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-type
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from './weixin';
import { markScanWarmupAfterAuth } from './shop-scan-auth';
import { request, saveWechatSession, type ShopSessionPayload } from './api';
export type ShopAccountProfile = {
@@ -162,15 +163,19 @@ export async function loginShopWithWechat(): Promise<ShopSessionPayload | null |
if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
}
markScanWarmupAfterAuth();
const result = await weixinSdk.login();
if (result) return handleShopWechatLoginResult(result);
}
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
export async function bindShopWechatAfterSmsLogin(): Promise<void> {
export async function bindShopWechatAfterSmsLogin(session?: ShopSessionPayload): Promise<'skipped' | void> {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return;
if (!isWechatEnv()) return;
if (!isWxAuthorizeEnabled(config)) return 'skipped';
if (!isWechatEnv()) return 'skipped';
// 已绑定则勿再 OAuth:每次 OAuth 回跳都会重置 iOS JSSDK 入场 URL,易导致扫码失败
if (session?.account?.hasWechat) return 'skipped';
markScanWarmupAfterAuth();
await weixinSdk.login();
}
@@ -180,5 +185,6 @@ export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
}
markScanWarmupAfterAuth();
return weixinSdk.login();
}
+47 -8
View File
@@ -4,7 +4,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isScanPermissionWarmupError } from '@dukang/weixin-sdk';
import { isIosDevice, isScanPermissionWarmupError } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext';
@@ -28,6 +28,8 @@ import {
clearPendingScanAfterAuth,
consumeScanWarmupAfterAuth,
getPostAuthScanDelayMs,
markPendingScanAfterAuth,
@@ -56,9 +58,9 @@ function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
if (/invalid signature/i.test(msg)) {
if (/invalid signature|config:fail|signature/i.test(msg)) {
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
return '微信扫码签名校验失败,请刷新页面或重新授权微信后重试';
}
@@ -66,11 +68,11 @@ function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
if (opts?.afterAuth) {
return '微信授权已完成,扫码权限仍在准备中,请再点一次「扫码核销」';
return '微信授权后扫码仍未就绪,请刷新页面或重新授权微信';
}
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
return '微信扫码能力未就绪,请刷新页面或重新授权微信';
}
@@ -78,6 +80,12 @@ function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
}
function isScanRecoverableError(msg: string): boolean {
return isScanPermissionWarmupError(msg) || /签名校验失败|扫码能力未就绪|请刷新页面/i.test(msg);
}
export default function HomePage() {
@@ -98,6 +106,8 @@ export default function HomePage() {
const [authModalOpen, setAuthModalOpen] = useState(false);
const [authModalMode, setAuthModalMode] = useState<'bind' | 'recover'>('bind');
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState('');
@@ -190,7 +200,8 @@ export default function HomePage() {
try {
if (opts?.postAuthWarmup) {
// iOS / OAuth 回跳后须重新 wx.config(签名用入场 URL
if (opts?.postAuthWarmup || isIosDevice()) {
weixinSdk.reset();
@@ -226,7 +237,19 @@ export default function HomePage() {
} catch (e) {
setScanMsg(formatScanError(e, { afterAuth: opts?.postAuthWarmup }));
const tip = formatScanError(e, { afterAuth: opts?.postAuthWarmup });
setScanMsg(tip);
if (isScanRecoverableError(tip)) {
setAuthModalMode('recover');
setAuthError(tip);
setAuthModalOpen(true);
}
} finally {
@@ -302,13 +325,19 @@ export default function HomePage() {
pendingScanStartedRef.current = false;
setAuthModalMode('bind');
setAuthError('');
setAuthModalOpen(true);
return;
}
await runScan();
const needWarmup = consumeScanWarmupAfterAuth();
await runScan(needWarmup ? { postAuthWarmup: true } : undefined);
} catch (e) {
@@ -556,16 +585,26 @@ export default function HomePage() {
open={authModalOpen}
mode={authModalMode}
loading={authLoading}
error={authError}
onAuthorize={() => void startWechatAuth()}
onRefresh={() => {
window.location.reload();
}}
onCancel={() => {
setAuthModalOpen(false);
setAuthModalMode('bind');
setAuthError('');
clearPendingScanAfterAuth();
+2 -2
View File
@@ -159,9 +159,9 @@ export default function LoginPage() {
});
saveRememberedSession(data);
applySession(data);
if (isWechatEnv() && wxAuthorize) {
if (isWechatEnv() && wxAuthorize && !data.account?.hasWechat) {
setMsg('登录成功,正在关联微信…');
await bindShopWechatAfterSmsLogin();
await bindShopWechatAfterSmsLogin(data);
return;
}
routeAfterShopLogin(data, navigate);
+13 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { StorePackagesResponse } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import ShopPackagesForm from '../components/ShopPackagesForm';
import { request } from '../lib/api';
@@ -30,7 +31,18 @@ export default function PackagesPage() {
: data.live?.length
? data.live
: [emptyPackage()];
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
setItems(
base.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
}),
);
setPending(data.pendingRequest ?? null);
setMsg('');
} catch (e) {
+16 -5
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext';
import {
needsStoreSelection,
@@ -10,6 +11,14 @@ import {
type ShopStoreOption,
} from '../lib/api';
function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) {
if (shouldHardNavigateForJssdk()) {
hardNavigateInWechat('/');
return;
}
navigate('/', { replace: true });
}
export default function SelectStorePage() {
const navigate = useNavigate();
const { applySession, store, authenticated } = useStoreSession();
@@ -37,7 +46,7 @@ export default function SelectStorePage() {
async function onSelect(storeId: string) {
if (loadingId) return;
if (storeId === currentStoreId) {
navigate('/', { replace: true });
goShopHome(navigate);
return;
}
setLoadingId(storeId);
@@ -45,7 +54,7 @@ export default function SelectStorePage() {
try {
const session = await selectStore(storeId);
applySession(session);
navigate('/', { replace: true });
goShopHome(navigate);
} catch (e) {
setMsg(e instanceof Error ? e.message : '选店失败');
} finally {
@@ -166,9 +175,11 @@ export function routeAfterShopLogin(
session: ShopSessionPayload,
navigate: (path: string, opts?: { replace?: boolean }) => void,
) {
if (needsStoreSelection(session)) {
navigate('/select-store', { replace: true });
const path = needsStoreSelection(session) ? '/select-store' : '/';
// iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂
if (shouldHardNavigateForJssdk()) {
hardNavigateInWechat(path);
return;
}
navigate('/', { replace: true });
navigate(path, { replace: true });
}
+1
View File
@@ -794,6 +794,7 @@
.shop-scan-auth-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
+1 -1
View File
@@ -13,6 +13,6 @@ export default defineConfig({
},
server: {
port: 5174,
proxy: { '/api': 'http://localhost:3000' },
proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010' },
},
});
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineConfig({
},
server: {
proxy: {
'/api': 'http://localhost:3000',
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
},
},
});
+2 -2
View File
@@ -16,7 +16,7 @@ export default defineConfig(async () => ({
plugins: ['@tarojs/plugin-html'],
defineConstants: {
/** H5 静态托管无 /api 代理时直连后端;dev 构建可通过 VITE_API_TARGET 覆盖 */
TARO_APP_API_ORIGIN: JSON.stringify(process.env.VITE_API_TARGET ?? 'http://localhost:3000'),
TARO_APP_API_ORIGIN: JSON.stringify(process.env.VITE_API_TARGET ?? 'http://localhost:3010'),
},
copy: {
patterns: [],
@@ -44,7 +44,7 @@ export default defineConfig(async () => ({
host: '0.0.0.0',
proxy: {
'/api': {
target: process.env.VITE_API_TARGET ?? 'http://localhost:3000',
target: process.env.VITE_API_TARGET ?? 'http://localhost:3010',
changeOrigin: true,
},
},
+1 -1
View File
@@ -5,7 +5,7 @@ function resolveApiBase(): string {
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
? TARO_APP_API_ORIGIN
: process.env.TARO_ENV === 'h5'
? 'http://localhost:3000'
? 'http://localhost:3010'
: '';
if (origin) {
return `${origin.replace(/\/$/, '')}/api/v1`;
+2 -2
View File
@@ -21,7 +21,7 @@ pnpm build:mini-user:weapp
| 环节 | 文件 | 说明 |
|------|------|------|
| **编译注入** | `config/index.ts``defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3000``build:weapp``--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 |
| **编译注入** | `config/index.ts``defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3010``build:weapp``--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 |
| **运行时拼装** | `src/lib/api.ts``resolveApiBase()` | `{origin}/api/v1` |
### 微信登录 `invalid code`
@@ -33,7 +33,7 @@ pnpm build:mini-user:weapp
| 小程序 appid | `project.config.json``wxda31c8e8e85051e7` |
| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) |
**本地联调(不接真实微信)**:保持默认即可(API → `localhost:3000`),并开启 Mock
**本地联调(不接真实微信)**:保持默认即可(API → `localhost:3010`),并开启 Mock
```bash
# 终端 1
+1 -1
View File
@@ -11,7 +11,7 @@ const isDevMode =
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
const API_ORIGIN =
process.env.VITE_API_TARGET ??
(isDevMode ? 'http://localhost:3000' : 'https://api.dukanghaoke.com');
(isDevMode ? 'http://localhost:3010' : 'https://api.dukanghaoke.com');
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@dukang/mini-user",
"version": "3.4.14",
"version": "3.4.15",
"private": true,
"description": "杜康好客 · C 端用户微信小程序(Taro)",
"scripts": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+1 -1
View File
@@ -10,7 +10,7 @@ function resolveApiBase(): string {
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
? TARO_APP_API_ORIGIN
: process.env.TARO_ENV === 'h5'
? 'http://localhost:3000'
? 'http://localhost:3010'
: '';
if (origin) {
return `${origin.replace(/\/$/, '')}/api/v1`;
+1 -1
View File
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro';
import { fetchClientConfig } from './pay-wechat';
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
export const APP_VERSION = '3.4.14';
export const APP_VERSION = '3.4.15';
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
+8 -2
View File
@@ -524,8 +524,13 @@ export default function MinePage() {
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
{profileSheetOpen ? (
<View className="mine-profile-sheet-mask" onClick={() => !savingProfile && setProfileSheetOpen(false)}>
<View className="mine-profile-sheet" onClick={(e) => e.stopPropagation()}>
<View className="mine-profile-sheet-mask">
{/* 遮罩单独绑 tap,勿在含 chooseAvatar 的祖先上用 stopPropagation(会编译成 catchtap 导致选头像无反应) */}
<View
className="mine-profile-sheet-backdrop"
onClick={() => !savingProfile && setProfileSheetOpen(false)}
/>
<View className="mine-profile-sheet">
<Text className="mine-profile-sheet-title"></Text>
<Text className="mine-profile-sheet-hint">
@@ -534,6 +539,7 @@ export default function MinePage() {
className="mine-profile-avatar-btn"
openType="chooseAvatar"
hoverClass="none"
plain
onChooseAvatar={onChooseAvatar}
>
<View className="mine-profile-avatar-preview">
@@ -143,7 +143,7 @@ export default function OrderConfirmPickupPage() {
const { confirm } = await Taro.showModal({
title: '确认提交订单',
content: `确认提交现场提货订单?共 ${quantity} 瓶,应付 ¥${Number(preview?.payAmount ?? 0).toFixed(2)}`,
content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家`,
confirmText: '确认提交',
cancelText: '再想想',
});
@@ -1,8 +1,10 @@
import { useCallback, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import { View, Text } from '@tarojs/components';
import Taro, { useLoad, useRouter } from '@tarojs/taro';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel';
import { request, toast } from '../../lib/api';
type StorePackage = {
@@ -12,6 +14,7 @@ type StorePackage = {
usableTime?: string | null;
otherNotes?: string | null;
imageUrl?: string | null;
imageUrls?: string[] | null;
};
type Store = {
@@ -97,10 +100,6 @@ export default function StorePackageDetailPage() {
}
}
function previewImage(url: string) {
Taro.previewImage({ urls: [url], current: url }).catch(() => toast('无法预览图片'));
}
if (loading) {
return (
<PageShell variant="scroll" className="store-package-detail-page">
@@ -119,7 +118,7 @@ export default function StorePackageDetailPage() {
);
}
const imageUrl = (pkg.imageUrl || '').trim();
const imageUrls = normalizeStorePackageImageUrls(pkg);
return (
<PageShell variant="scroll" className="store-package-detail-page">
@@ -137,15 +136,13 @@ export default function StorePackageDetailPage() {
) : null}
</View>
{imageUrl ? (
<View
className="store-package-detail-photo"
onClick={() => previewImage(imageUrl)}
>
<Image
className="store-package-detail-photo-image"
src={imageUrl}
mode="widthFix"
{imageUrls.length > 0 ? (
<View className="store-package-detail-gallery">
<ProductCarousel
images={imageUrls}
alt={pkg.name}
variant="detail"
previewable
/>
</View>
) : null}
+21 -27
View File
@@ -40,6 +40,7 @@ import {
toWeappShareMessage,
toWeappShareTimeline,
} from '../../lib/wechat-share';
import openBadgeImg from '../../assets/icons/store-open-badge.png';
type Store = {
id: string;
@@ -341,12 +342,12 @@ export default function StoresPage() {
}
}
function hoursLines(store: Store): string[] {
function hoursText(store: Store): string {
const parts: string[] = [];
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
if (!parts.length) parts.push('10:00-22:00');
return parts.map((p, i) => (i === 0 ? `营业时间: ${p}` : p));
return `营业时间: ${parts.join(' ')}`;
}
const sharePayload = useMemo(
@@ -421,45 +422,38 @@ export default function StoresPage() {
className="store-card"
onClick={() => Taro.navigateTo({ url: `/pages/store-detail/index?id=${s.id}` })}
>
<View className="store-card-cover-wrap">
{s.coverUrl ? (
<Image className="store-card-cover" src={s.coverUrl} mode="aspectFill" />
) : (
<View className="store-card-cover store-card-cover--empty" />
)}
<Image
className="store-card-open-badge"
src={openBadgeImg}
mode="aspectFit"
/>
</View>
<View className="store-card-body">
{/* 第1行:标题 + 距离 */}
{/* 第1行:标题(截断无省略号,顶到最右) */}
<View className="store-card-row store-card-row--head">
<Text className="store-card-name" numberOfLines={1}>
{s.name}
<Text className="store-card-name">{s.name}</Text>
</View>
{/* 第2行:地址(最多两行)+ 距离 */}
<View className="store-card-row store-card-row--mid">
<Text className="store-card-address" numberOfLines={2}>
{s.address || (s.district ? `${s.district}` : '地址待完善')}
</Text>
<Text className="store-card-distance">
{formatDistanceMeters(s.distanceMeters)}
</Text>
</View>
{/* 第2行:营业时间(多段各占一行,居左 */}
<View className="store-card-hours">
{hoursLines(s).map((line) => (
<Text key={line} className="store-card-hours-line">
{line}
</Text>
))}
</View>
{/* 第3行:地址(最多两行)+ 去核销 */}
<View className="store-card-row store-card-row--foot">
<Text className="store-card-address" numberOfLines={2}>
{s.address || (s.district ? `${s.district}` : '地址待完善')}
</Text>
<View
className="store-card-cta"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/redeem/index' });
}}
>
<Text className="store-card-cta-text"></Text>
</View>
{/* 第3行:营业时间(同行 */}
<View className="store-card-row store-card-row--hours">
<Text className="store-card-hours">{hoursText(s)}</Text>
</View>
</View>
<Text className="store-card-arrow"></Text>
</View>
))}
</View>
+16 -3
View File
@@ -386,13 +386,20 @@
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(20, 16, 14, 0.45);
display: flex;
align-items: flex-end;
justify-content: center;
}
.mine-profile-sheet-backdrop {
position: absolute;
inset: 0;
background: rgba(20, 16, 14, 0.45);
}
.mine-profile-sheet {
position: relative;
z-index: 1;
width: 100%;
max-width: 480px;
box-sizing: border-box;
@@ -422,8 +429,9 @@
margin: 20px auto 0;
padding: 0;
width: auto;
background: transparent;
border: none;
height: auto;
background: transparent !important;
border: none !important;
line-height: 1.2;
display: flex;
flex-direction: column;
@@ -434,6 +442,11 @@
border: none;
}
/* 子节点不抢触摸,保证 open-type=chooseAvatar 由 Button 本人响应 */
.mine-profile-avatar-btn > * {
pointer-events: none;
}
.mine-profile-avatar-preview {
width: 88px;
height: 88px;
+3 -5
View File
@@ -352,7 +352,7 @@
padding-left: 4px;
}
.store-package-detail-photo {
.store-package-detail-gallery {
margin: 14px 0 0;
border-radius: var(--radius-lg);
overflow: hidden;
@@ -360,10 +360,8 @@
box-shadow: var(--shadow-card);
}
.store-package-detail-photo-image {
width: 100%;
display: block;
vertical-align: top;
.store-package-detail-gallery .detail-carousel-wrap {
border-radius: 0;
}
.store-package-detail-content {
+68 -61
View File
@@ -144,12 +144,12 @@
padding: 4px var(--space-page) 16px;
}
/* 左图右文 */
/* 左图 + 中间文案 + 右侧箭头 */
.store-card {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 12px;
align-items: center;
gap: 10px;
padding: 12px;
box-sizing: border-box;
background: var(--color-card);
@@ -158,11 +158,19 @@
margin-bottom: 12px;
}
.store-card-cover {
.store-card-cover-wrap {
position: relative;
flex-shrink: 0;
width: 96px;
height: 96px;
border-radius: 8px;
overflow: hidden;
}
.store-card-cover {
width: 100%;
height: 100%;
border-radius: 8px;
background: var(--color-surface-container);
display: block;
}
@@ -171,6 +179,16 @@
background: var(--color-surface-container);
}
.store-card-open-badge {
position: absolute;
top: 0;
right: 0;
width: 44px;
height: 44px;
pointer-events: none;
z-index: 1;
}
.store-card-body {
flex: 1;
min-width: 0;
@@ -184,19 +202,19 @@
.store-card-row {
display: flex;
align-items: center;
align-items: flex-start;
min-width: 0;
overflow: hidden;
}
/* 第1行:加粗标题(单行截断+ 右对齐距离 */
/* 第1行:加粗标题(单行截断,不显示 …,宽度顶到最右) */
.store-card-row--head {
gap: 8px;
height: 22px;
align-items: center;
}
.store-card-name {
flex: 1;
width: 100%;
min-width: 0;
font-family: var(--font-headline);
font-size: 15px;
@@ -204,53 +222,23 @@
line-height: 22px;
color: #1a1a1a;
overflow: hidden;
text-overflow: ellipsis;
text-overflow: clip;
white-space: nowrap;
}
.store-card-distance {
flex-shrink: 0;
max-width: 40%;
font-size: 12px;
font-weight: 400;
line-height: 22px;
color: #999;
text-align: right;
white-space: nowrap;
}
/* 第2行:营业时间独自居左;多段各占一行 */
.store-card-hours {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
}
.store-card-hours-line {
font-size: 11px;
font-weight: 400;
line-height: 16px;
color: #999;
text-align: left;
}
/* 第3行:地址最多两行截断 + 右对齐去核销;与按钮垂直居中 */
.store-card-row--foot {
/* 第2行:地址最多两行 + 右对齐距离 */
.store-card-row--mid {
gap: 8px;
min-height: 28px;
align-items: center;
overflow: visible;
align-items: flex-start;
}
.store-card-address {
flex: 1;
min-width: 0;
font-size: 9px;
font-size: 11px;
font-weight: 400;
line-height: 14px;
max-height: 28px;
line-height: 16px;
max-height: 32px;
color: #999;
text-align: left;
white-space: normal;
@@ -260,25 +248,44 @@
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
text-overflow: ellipsis;
}
.store-card-cta {
.store-card-distance {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0 14px;
height: 26px;
border-radius: 999px;
background: var(--color-heritage-red, #a61d24);
box-sizing: border-box;
}
.store-card-cta-text {
max-width: 40%;
padding-top: 1px;
font-size: 12px;
font-weight: 600;
line-height: 26px;
color: #fff;
font-weight: 400;
line-height: 16px;
color: #999;
text-align: right;
white-space: nowrap;
}
/* 第3行:营业时间同行 */
.store-card-row--hours {
align-items: center;
}
.store-card-hours {
width: 100%;
min-width: 0;
font-size: 11px;
font-weight: 400;
line-height: 16px;
color: #999;
overflow: hidden;
text-overflow: clip;
white-space: nowrap;
}
.store-card-arrow {
flex-shrink: 0;
align-self: center;
width: 16px;
font-size: 20px;
font-weight: 300;
line-height: 1;
color: #ccc;
text-align: center;
}
@@ -18,6 +18,7 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'llm_configs', label: '语言模型配置', group: '业务' },
{ key: 'knowledge_bases', label: '知识库', group: '业务' },
{ key: 'dev_plan', label: '开发计划', group: '业务' },
{ key: 'test_whitelist', label: '白名单管理', group: '业务' },
{ key: 'resources', label: 'OSS 资源库', group: '业务' },
{ key: 'logs', label: '日志', group: '业务' },
{ key: 'users_delete', label: '删除用户', group: '危险操作' },
@@ -116,6 +117,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
'llm_configs',
'knowledge_bases',
'dev_plan',
'test_whitelist',
'resources',
'logs',
'system_settings_wechat_mini',
@@ -5,7 +5,10 @@ export interface StorePackageItemDto {
dishes: string;
usableTime?: string | null;
otherNotes?: string | null;
/** 首图(兼容旧字段;多图时等于 imageUrls[0] */
imageUrl?: string | null;
/** 套餐图片列表,最多 STORE_PACKAGE_IMAGE_MAX_COUNT 张 */
imageUrls?: string[] | null;
sortOrder: number;
}
@@ -25,6 +28,49 @@ export const STORE_PACKAGE_CHANGE_STATUS_LABELS: Record<StorePackageChangeStatus
export const STORE_PACKAGE_MAX_COUNT = 10;
/** 单条套餐最多上传图片数 */
export const STORE_PACKAGE_IMAGE_MAX_COUNT = 20;
/** 门店环境照最多张数(总部/合伙人上传) */
export const STORE_ENV_PHOTO_MAX_COUNT = 20;
/** 归一化套餐图片:兼容 imageUrl / imageUrls(含 JSON 字符串),去重后截断上限 */
export function normalizeStorePackageImageUrls(input: {
imageUrl?: string | null;
imageUrls?: unknown;
}): string[] {
const seen = new Set<string>();
const out: string[] = [];
const push = (raw: unknown) => {
const url = String(raw ?? '').trim();
if (!url || seen.has(url)) return;
seen.add(url);
out.push(url);
};
let list: unknown = input.imageUrls;
if (typeof list === 'string') {
const trimmed = list.trim();
if (trimmed.startsWith('[')) {
try {
list = JSON.parse(trimmed);
} catch {
list = trimmed ? [trimmed] : [];
}
} else if (trimmed) {
list = [trimmed];
} else {
list = [];
}
}
if (Array.isArray(list)) {
for (const item of list) push(item);
}
if (out.length === 0) push(input.imageUrl);
return out.slice(0, STORE_PACKAGE_IMAGE_MAX_COUNT);
}
export interface StorePackagesResponse {
live: StorePackageViewDto[];
pendingRequest?: {
+34
View File
@@ -0,0 +1,34 @@
# @dukang/weixin-sdk · 踩坑
## iOS 微信 H5JSSDK 入场 URL(扫码 / 定位 / 选图)
### 症状
- 登录或 OAuth 后立刻调 `scanQRCode` / `getLocation` / `chooseImage` 失败
- 错误类似:`permission value is offline verifying``invalid signature`
- 业务文案常被写成「权限校验尚未完成,请等 1~2 秒」——**多数情况下等无效**
- 关掉 webview 再进(整页重载)立即恢复
### 原因
iOS 微信对 JS-SDK 验签使用的是**本次 document 加载时的 URL**(去掉 `#` 后的完整 URL**含 query**)。
`history.pushState` / `replaceState`(含 React Router)**不会**更新微信内部用于验签的 URL。
典型错误链路:
1. OAuth 回跳:`/login?code=xxx&state=yyy`(入场 URL
2. SPA`navigate('/')`
3. 用当前页 `/` 或「去掉 code 后的 `/login`」去签名 → 与微信内部 URL 不一致 → 失败
### 正确做法
1. **业务跳转(登录成功 / 选店进首页)**iOS 微信内用 `hardNavigateInWechat(path)` / `location.replace`,让目标页成为新的入场 URL。
2. **签名 URL**`getJssdkSignUrl()` 在 iOS 上返回入场 URLOAuth 的 `code/state` **必须保留**参与签名;后端 `jssdk-config` 只去 `#`,不要删 query。
3. **先 `captureIosJssdkEntryUrl()`,再 `stripOAuthParamsFromLocation()`**
4. 失败恢复:引导用户刷新页面或重新走 OAuth,而不是无限「再点一次」。
### 相关 API
- `captureIosJssdkEntryUrl` / `getJssdkSignUrl`
- `shouldHardNavigateForJssdk` / `hardNavigateInWechat`
- `stripOAuthParamsFromLocation`
+3
View File
@@ -5,10 +5,13 @@ export {
ensureJssdkReady,
isJssdkReady,
normalizeJssdkPageUrl,
jssdkUrlWithoutHash,
getJssdkSignUrl,
captureIosJssdkEntryUrl,
resetJssdkConfig,
stripOAuthParamsFromLocation,
hardNavigateInWechat,
shouldHardNavigateForJssdk,
} from './jssdk';
export { formatScanFailMessage, isScanPermissionWarmupError } from './scan';
export {
+69 -22
View File
@@ -1,31 +1,53 @@
import type { WechatJssdkConfig } from '@dukang/shared-types';
import { isWechatBrowser, isWechatDevTools } from './env';
import { isIosDevice, isWechatBrowser, isWechatDevTools } from './env';
import { DEFAULT_JS_API_LIST } from './types';
const JSSDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const SIGN_URL_CACHE_KEY = 'dukang_wx_sign_url_v2';
/** 旧版错误地把 SPA 当前 URL 写入 session;清理以免干扰排查 */
const LEGACY_SIGN_URL_CACHE_KEY = 'dukang_wx_sign_url_v2';
let scriptPromise: Promise<void> | null = null;
let configured = false;
let configuredUrl: string | null = null;
/**
* iOS 微信 WebViewJSSDK 签名校验用的是「本次 document 加载」的入场 URL(含 query),
* SPA pushState/replaceState 后 location.href 会变,但微信仍按入场 URL 验签。
* OAuth 回跳带 code/state 时也必须按入场完整 query 签名,不可剔除。
* 模块级变量:整页刷新(含 OAuth / location.replace)会重置;同页 SPA 保持不变。
*/
let iosEntryUrl: string | null = null;
/** 清除 JSSDK 配置缓存(路由切换后须重新 wx.config) */
export function resetJssdkConfig(): void {
configured = false;
configuredUrl = null;
}
/** 参与 JSSDK 签名的页面 URL:与微信文档一致,取 location.href 去掉 # 后的部分;剔除 OAuth 回调参数 */
export function normalizeJssdkPageUrl(rawUrl: string): string {
/** 仅去 hash,保留全部 query(含 OAuth code/state)— iOS 入场签名必须如此 */
export function jssdkUrlWithoutHash(rawUrl: string): string {
return rawUrl.split('#')[0];
}
/**
* 规范化签名 URL。
* - 默认:去 hash;可保留 code/state(由 keepOAuthQuery 控制)
* - Android / 当前页签名:通常已 stripOAuth 后再签,keepOAuthQuery=false
*/
export function normalizeJssdkPageUrl(rawUrl: string, opts?: { keepOAuthQuery?: boolean }): string {
const keepOAuth = !!opts?.keepOAuthQuery;
try {
const url = new URL(rawUrl);
url.hash = '';
if (!keepOAuth) {
url.searchParams.delete('code');
url.searchParams.delete('state');
}
const query = url.searchParams.toString();
return `${url.origin}${url.pathname}${query ? `?${query}` : ''}`;
} catch {
const noHash = rawUrl.split('#')[0];
const noHash = jssdkUrlWithoutHash(rawUrl);
if (keepOAuth) return noHash;
try {
const url = new URL(noHash, typeof window !== 'undefined' ? window.location.origin : 'https://localhost');
url.searchParams.delete('code');
@@ -42,25 +64,48 @@ function signUrlChanged(prev: string | null, current: string): boolean {
return prev !== current;
}
/** 记录最近一次签名 URL;SPA 路由或 ?step= 变化时须重新 wx.config */
export function captureIosJssdkEntryUrl(): void {
if (typeof window === 'undefined') return;
if (!isWechatBrowser() || isWechatDevTools()) return;
const current = normalizeJssdkPageUrl(window.location.href);
const existing = sessionStorage.getItem(SIGN_URL_CACHE_KEY);
if (!existing) {
sessionStorage.setItem(SIGN_URL_CACHE_KEY, current);
return;
}
if (signUrlChanged(existing, current)) {
sessionStorage.setItem(SIGN_URL_CACHE_KEY, current);
resetJssdkConfig();
function clearLegacySignUrlCache(): void {
try {
sessionStorage.removeItem(LEGACY_SIGN_URL_CACHE_KEY);
} catch {
/* ignore */
}
}
/** 获取参与 JSSDK 签名的 URL(始终为当前页完整 URL,含 query) */
/**
* 捕获 iOS 微信入场 URL(每个 document 生命周期只记一次;保留 code/state)。
* Android / 非微信环境为 no-op。
*/
export function captureIosJssdkEntryUrl(): void {
if (typeof window === 'undefined') return;
if (!isIosDevice() || !isWechatBrowser() || isWechatDevTools()) return;
clearLegacySignUrlCache();
if (iosEntryUrl) return;
iosEntryUrl = normalizeJssdkPageUrl(window.location.href, { keepOAuthQuery: true });
}
/** 获取参与 JSSDK 签名的 URL;iOS 微信内固定为本次入场 URL(含 OAuth query */
export function getJssdkSignUrl(rawUrl?: string): string {
return normalizeJssdkPageUrl(rawUrl ?? (typeof window !== 'undefined' ? window.location.href : ''));
if (typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools()) {
captureIosJssdkEntryUrl();
if (iosEntryUrl) return iosEntryUrl;
}
return normalizeJssdkPageUrl(rawUrl ?? (typeof window !== 'undefined' ? window.location.href : ''), {
keepOAuthQuery: false,
});
}
/**
* iOS 微信内业务跳转须用整页导航,使下一页成为新的 JSSDK 入场 URL。
* SPA navigate 会导致扫码/定位等 JSAPI 验签失败。
*/
export function hardNavigateInWechat(path: string): void {
if (typeof window === 'undefined') return;
window.location.replace(path);
}
export function shouldHardNavigateForJssdk(): boolean {
return typeof window !== 'undefined' && isIosDevice() && isWechatBrowser() && !isWechatDevTools();
}
function isJssdkDebugEnabled(): boolean {
@@ -76,6 +121,8 @@ export function stripOAuthParamsFromLocation(): void {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
if (!url.searchParams.has('code') && !url.searchParams.has('state')) return;
// iOS:须先锁定入场 URL(含 code/state),再 replaceState
captureIosJssdkEntryUrl();
url.searchParams.delete('code');
url.searchParams.delete('state');
const query = url.searchParams.toString();
@@ -126,7 +173,7 @@ export async function initWechatJssdk(options: {
}): Promise<void> {
captureIosJssdkEntryUrl();
const { apiBase, clientApp, getAccessToken } = options;
const pageUrl = options.url ?? getJssdkSignUrl();
const pageUrl = getJssdkSignUrl(options.url);
await loadScript();
if (!window.wx) throw new Error('微信 JSSDK 不可用');
@@ -164,7 +211,7 @@ export async function ensureJssdkReady(options: {
jsApiList?: string[];
}): Promise<void> {
captureIosJssdkEntryUrl();
const pageUrl = options.url ?? getJssdkSignUrl();
const pageUrl = getJssdkSignUrl(options.url);
if (configuredUrl && signUrlChanged(configuredUrl, pageUrl)) {
resetJssdkConfig();
}
+1 -1
View File
@@ -8,7 +8,7 @@ DATABASE_URL="mysql://root:root@localhost:6016/dukang_haoke"
REDIS_URL="redis://localhost:6017"
JWT_SECRET="dukang-prev1-dev-secret-change-in-prod"
JWT_EXPIRES_IN="7d"
PORT=3000
PORT=3010
MOCK_SMS=true
# MOCK_SMS=false 时必填(可与 OSS 共用 RAM)
ALIYUN_SMS_SIGN_NAME=
+39
View File
@@ -812,6 +812,21 @@ model CommonProductVisibilityPhone {
@@map("common_product_visibility_phone")
}
/// 全局测试白名单手机号(测试账号 + 限测商品/门店可见)
model CommonTestWhitelistPhone {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
note String? @db.VarChar(256)
createdByHqId BigInt? @map("created_by_hq_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
createdBy HqAccount? @relation("TestWhitelistCreatedBy", fields: [createdByHqId], references: [id], onDelete: SetNull)
@@index([createdAt])
@@map("common_test_whitelist_phone")
}
model CommonProductDetailTemplate {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
@@ -1043,6 +1058,8 @@ model PartnerAccount {
bankBranch String? @map("bank_branch") @db.VarChar(128)
weeklyStoreTarget Int? @default(20) @map("weekly_store_target")
managedWarehouseId BigInt? @unique @map("managed_warehouse_id") @db.UnsignedBigInt
/// 测试合伙人账号
isTest Boolean @default(false) @map("is_test")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1059,6 +1076,7 @@ model PartnerAccount {
@@index([parentAccountId])
@@index([wxOpenId])
@@index([contactPhone])
@@index([isTest])
@@map("partner_account")
}
@@ -1102,6 +1120,7 @@ model HqAccount {
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
permissions HqAccountPermission[]
testWhitelistPhones CommonTestWhitelistPhone[] @relation("TestWhitelistCreatedBy")
@@map("hq_account")
}
@@ -1140,6 +1159,8 @@ model User {
nickname String? @db.VarChar(64)
avatarResourceId BigInt? @map("avatar_resource_id") @db.UnsignedBigInt
status Int @default(1) @db.TinyInt
/// 测试账号:命中全局测试白名单手机号
isTest Boolean @default(false) @map("is_test")
sourceType UserSourceType @default(ORGANIC) @map("source_type")
sourceRefId BigInt? @map("source_ref_id") @db.UnsignedBigInt
sourceLabel String? @map("source_label") @db.VarChar(128)
@@ -1166,6 +1187,7 @@ model User {
@@index([referrerUserId])
@@index([mergedIntoUserId])
@@index([wxOpenId])
@@index([isTest])
@@map("user_user")
}
@@ -1253,6 +1275,10 @@ model Store {
visibilityWhitelistEnabled Boolean @default(false) @map("visibility_whitelist_enabled")
/// FIN-001:允许未出账手动提现的白名单门店
withdrawWhitelistEnabled Boolean @default(false) @map("withdraw_whitelist_enabled")
/// 测试门店:不计结算 / HQ 可手动标记
isTest Boolean @default(false) @map("is_test")
/// C 端 / 列表排序:数值越小越靠前
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1274,6 +1300,8 @@ model Store {
@@index([cityId, status])
@@index([partnerAccountId])
@@index([auditStatus, createdAt])
@@index([isTest])
@@index([sortOrder])
@@map("store_store")
}
@@ -1300,6 +1328,8 @@ model StorePackage {
usableTime String? @map("usable_time") @db.VarChar(256)
otherNotes String? @map("other_notes") @db.VarChar(512)
imageUrl String? @map("image_url") @db.VarChar(512)
/// 套餐多图 URL 列表(JSON string[]),最多 20 张;imageUrl 同步为首图
imageUrls Json? @map("image_urls")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1344,6 +1374,8 @@ model StoreAccount {
bankBranch String? @map("bank_branch") @db.VarChar(128)
status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
/// 测试门店账号(商户)
isTest Boolean @default(false) @map("is_test")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1354,6 +1386,7 @@ model StoreAccount {
withdrawRequests StoreWithdrawRequest[]
@@index([parentAccountId])
@@index([isTest])
@@map("store_account")
}
@@ -1432,6 +1465,8 @@ model Order {
fulfillmentHold Boolean @default(false) @map("fulfillment_hold")
fulfillmentHoldReason String? @map("fulfillment_hold_reason") @db.VarChar(64)
remark String? @db.VarChar(512)
/// 测试订单快照(下单时取自 User.isTest
isTest Boolean @default(false) @map("is_test")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -1456,6 +1491,7 @@ model Order {
@@index([gpsCity])
@@index([fulfillmentWarehouseId])
@@index([proxyPartnerAccountId])
@@index([isTest])
@@map("user_order")
}
@@ -1548,6 +1584,8 @@ model RedeemRecord {
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
/// SCAN=qrcode, PHONE=phone
channel RedeemChannel @default(SCAN)
/// 测试核销快照(User.isTest || Store.isTest
isTest Boolean @default(false) @map("is_test")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
@@ -1560,6 +1598,7 @@ model RedeemRecord {
@@index([storeId, createdAt])
@@index([storeId, channel, createdAt])
@@index([isTest])
@@map("user_redeem_record")
}
+44
View File
@@ -61,6 +61,16 @@ async function main() {
await prisma.storePayout.deleteMany();
await prisma.storeWithdrawPayoutItem.deleteMany();
await prisma.storeWithdrawRequest.deleteMany();
await prisma.storeBill.deleteMany();
await prisma.redeemPendingRecord.deleteMany();
await prisma.logStoreAnalytics.deleteMany();
await prisma.storeRating.deleteMany();
await prisma.redeemRecord.deleteMany();
@@ -83,16 +93,28 @@ async function main() {
await prisma.user.deleteMany();
await prisma.storeAccount.updateMany({ data: { parentAccountId: null } });
await prisma.storeAccount.deleteMany();
await prisma.store.deleteMany();
await prisma.partnerBill.deleteMany();
await prisma.logisticsBillItem.deleteMany();
await prisma.logisticsPrepaidLedger.deleteMany();
await prisma.logisticsBill.deleteMany();
await prisma.wineryBillItem.deleteMany();
await prisma.wineryBill.deleteMany();
await prisma.cityWarehouse.deleteMany();
await prisma.fulfillmentProvider.deleteMany();
await prisma.partnerAccount.updateMany({ data: { parentAccountId: null } });
await prisma.partnerAccount.deleteMany();
await prisma.commonCity.deleteMany();
@@ -101,6 +123,7 @@ async function main() {
await prisma.commonProductDetailTemplate.deleteMany();
await prisma.commonStoreCategory.updateMany({ data: { parentId: null } });
await prisma.commonStoreCategory.deleteMany();
await prisma.commonPromoCode.deleteMany();
@@ -914,6 +937,25 @@ async function main() {
const testWhitelistPhones = [
'13800000001',
'13700000001',
'13700000002',
'13910000001',
'13910000002',
];
for (const phone of testWhitelistPhones) {
await prisma.commonTestWhitelistPhone.upsert({
where: { phone },
create: { phone, note: 'seed 测试账号' },
update: {},
});
await prisma.user.updateMany({ where: { phone }, data: { isTest: true } });
await prisma.storeAccount.updateMany({ where: { phone }, data: { isTest: true } });
await prisma.partnerAccount.updateMany({ where: { phone }, data: { isTest: true } });
await prisma.store.updateMany({ where: { phone }, data: { isTest: true } });
}
console.log('Seed complete:', {
city: city.name,
@@ -928,6 +970,8 @@ async function main() {
stores: createdStores.length,
testWhitelistPhones,
testPhones: {
user: '13800000001',
+2
View File
@@ -21,6 +21,7 @@ import { CityScopeModule } from './modules/city-scope/city-scope.module';
import { CommonModule } from './modules/common/common.module';
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
import { SystemConfigModule } from './common/system-config/system-config.module';
import { TestWhitelistModule } from './common/test-whitelist/test-whitelist.module';
import { DevPlanModule } from './modules/dev-plan/dev-plan.module';
import { CallbacksModule } from './callbacks/callbacks.module';
import { WecomModule } from './integrations/wecom/wecom.module';
@@ -37,6 +38,7 @@ import { RequestIdMiddleware } from './common/logging/request-id.middleware';
}),
PrismaModule,
SystemConfigModule,
TestWhitelistModule,
GeoModule,
RedisModule,
AlertModule,
@@ -1,11 +1,19 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../common/prisma/prisma.module';
import { CourierService } from '../integrations/courier/courier.service';
import { parseCourierCallbackBody } from '../integrations/courier/xiaofeixia/xiaofeixia-callback-body';
import { XiaofeixiaProvider } from '../integrations/courier/xiaofeixia/xiaofeixia.provider';
import { logCourierCall } from '../integrations/courier/courier-log.util';
import { TradeService } from '../modules/trade/trade.service';
const XFX_PROVIDER_ALIASES = new Set(['xfx', 'xiaofeixia']);
const RAW_BODY_LOG_LIMIT = 4000;
export type TrackCallbackRequestMeta = {
contentType?: string | null;
rawBody?: string | null;
query?: Record<string, unknown>;
};
@Injectable()
export class DeliveryCallbackService {
@@ -16,15 +24,17 @@ export class DeliveryCallbackService {
private readonly tradeService: TradeService,
) {}
async handleTrackCallback(providerKey: string, body: unknown, requestUrl: string) {
async handleTrackCallback(
providerKey: string,
body: unknown,
requestUrl: string,
meta?: TrackCallbackRequestMeta,
) {
const normalized = providerKey.trim().toLowerCase();
const baseLog = {
scene: 'TRACK_CALLBACK',
requestUrl,
requestBody: (body && typeof body === 'object' ? body : { value: body }) as Record<
string,
unknown
>,
requestBody: this.buildRequestBodyForLog(body, meta),
};
if (!XFX_PROVIDER_ALIASES.has(normalized) && normalized !== 'logistics') {
@@ -45,7 +55,9 @@ export class DeliveryCallbackService {
return this.courier.buildTrackCallbackResponse(true);
}
const payload = this.xiaofeixiaProvider.parseTrackCallback(body);
const payload =
this.xiaofeixiaProvider.parseTrackCallback(body) ??
this.parseFromRawMeta(meta);
if (!payload) {
const response = this.courier.buildTrackCallbackResponse(false);
await logCourierCall(this.prisma, {
@@ -105,4 +117,65 @@ export class DeliveryCallbackService {
return response;
}
/** 第三方日志:保留解析后字段,并附带 Content-Type / rawBody / query 便于排查空 body */
private buildRequestBodyForLog(
body: unknown,
meta?: TrackCallbackRequestMeta,
): Record<string, unknown> {
const parsed =
body && typeof body === 'object'
? ({ ...(body as Record<string, unknown>) } as Record<string, unknown>)
: body === undefined || body === null
? {}
: { value: body };
const rawBody = meta?.rawBody != null ? this.redactSecrets(String(meta.rawBody)) : null;
const query =
meta?.query && Object.keys(meta.query).length > 0
? this.redactSecretFields({ ...meta.query })
: undefined;
return {
...this.redactSecretFields(parsed),
_meta: {
contentType: meta?.contentType ?? null,
rawBody:
rawBody && rawBody.length > RAW_BODY_LOG_LIMIT
? `${rawBody.slice(0, RAW_BODY_LOG_LIMIT)}…(truncated)`
: rawBody,
rawBodyLength: meta?.rawBody != null ? Buffer.byteLength(meta.rawBody, 'utf8') : 0,
query,
},
};
}
private redactSecretFields(input: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
if (/^sign$/i.test(key) || /api[_-]?key/i.test(key)) {
out[key] = '[REDACTED]';
} else if (key === 'mchId' && value != null) {
const s = String(value);
out[key] = s.length <= 4 ? '****' : `${s.slice(0, 4)}****`;
} else {
out[key] = value;
}
}
return out;
}
private parseFromRawMeta(meta?: TrackCallbackRequestMeta) {
if (!meta?.rawBody?.trim()) return null;
const parsed = parseCourierCallbackBody(meta.rawBody, meta.contentType || '');
return this.xiaofeixiaProvider.parseTrackCallback(parsed);
}
private redactSecrets(raw: string): string {
return raw
.replace(/(sign=)[^&\s]*/gi, '$1[REDACTED]')
.replace(/("sign"\s*:\s*")[^"]*/gi, '$1[REDACTED]')
.replace(/(api[_-]?key=)[^&\s]*/gi, '$1[REDACTED]')
.replace(/(name="sign"[\s\S]*?\r?\n\r?\n)([^\r\n-]+)/gi, '$1[REDACTED]');
}
}
@@ -1,7 +1,9 @@
import { Body, Controller, Param, Post, Res } from '@nestjs/common';
import type { Response } from 'express';
import { Body, Controller, Param, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { DeliveryCallbackService } from './delivery-callback.service';
type CourierCallbackRequest = Request & { rawBody?: Buffer };
@Controller('callbacks')
export class DeliveryCallbackController {
constructor(private readonly deliveryCallbackService: DeliveryCallbackService) {}
@@ -11,12 +13,14 @@ export class DeliveryCallbackController {
async trackByProvider(
@Param('provider') provider: string,
@Body() body: unknown,
@Req() req: CourierCallbackRequest,
@Res() res: Response,
) {
const result = await this.deliveryCallbackService.handleTrackCallback(
provider,
body,
`/api/v1/callbacks/courier/${provider}/track`,
this.buildRequestMeta(req),
);
// 直出承运商约定结构,避免被全局 { code:0, data } 包装
return res.status(200).json(result);
@@ -24,12 +28,29 @@ export class DeliveryCallbackController {
/** 兼容旧路径,默认按小飞侠解析 */
@Post('delivery/track')
async trackLegacy(@Body() body: unknown, @Res() res: Response) {
async trackLegacy(
@Body() body: unknown,
@Req() req: CourierCallbackRequest,
@Res() res: Response,
) {
const result = await this.deliveryCallbackService.handleTrackCallback(
'xfx',
body,
'/api/v1/callbacks/delivery/track',
this.buildRequestMeta(req),
);
return res.status(200).json(result);
}
private buildRequestMeta(req: CourierCallbackRequest) {
const contentType = req.headers['content-type'];
return {
contentType: Array.isArray(contentType) ? contentType.join(', ') : contentType || null,
rawBody: req.rawBody?.toString('utf8') ?? null,
query:
req.query && typeof req.query === 'object'
? (req.query as Record<string, unknown>)
: undefined,
};
}
}
@@ -7,11 +7,10 @@ import {
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
HQ_DANGEROUS_PERMISSION_KEYS,
HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS,
expandHqPermissionKeys,
hasAnySystemSettingsPermission,
hqBasePermissionKeys,
type HqPermissionKey,
} from '@dukang/shared-types';
import { PrismaService } from '../prisma/prisma.module';
@@ -28,7 +27,7 @@ export const RequireAnySystemSettings = () =>
export class HqPermissionsResolver {
constructor(private readonly prisma: PrismaService) {}
async resolveEffectiveKeys(actorId: bigint): Promise<HqPermissionKey[]> {
private async loadActiveAccount(actorId: bigint) {
const account = await this.prisma.hqAccount.findUnique({
where: { id: actorId },
select: { adminRole: true, status: true },
@@ -36,6 +35,14 @@ export class HqPermissionsResolver {
if (!account || account.status !== 'ACTIVE') {
throw new ForbiddenException('HQ 账号不可用');
}
return account;
}
async resolveAccess(actorId: bigint): Promise<{
keys: HqPermissionKey[];
isSuperAdmin: boolean;
}> {
const account = await this.loadActiveAccount(actorId);
const userRows = await this.prisma.hqAccountPermission.findMany({
where: { hqAccountId: actorId },
@@ -44,12 +51,14 @@ export class HqPermissionsResolver {
const userKeys = userRows.map((r) => r.permissionKey);
if (account.adminRole === 'SUPER_ADMIN') {
// 超管含危险操作(删用户/订单/城市);其他角色仍需在权限分配中显式勾选
return expandHqPermissionKeys([
...hqBasePermissionKeys(),
...HQ_DANGEROUS_PERMISSION_KEYS,
// 超管拥有权限目录内全部项(含后续新增),另含危险操作与用户级附加项
return {
isSuperAdmin: true,
keys: expandHqPermissionKeys([
...HQ_PERMISSION_CATALOG.map((p) => p.key),
...userKeys,
]);
]),
};
}
const roleRows = await this.prisma.hqRolePermission.findMany({
@@ -62,7 +71,15 @@ export class HqPermissionsResolver {
? roleRows.map((r) => r.permissionKey)
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])];
return expandHqPermissionKeys([...roleKeys, ...userKeys]);
return {
isSuperAdmin: false,
keys: expandHqPermissionKeys([...roleKeys, ...userKeys]),
};
}
async resolveEffectiveKeys(actorId: bigint): Promise<HqPermissionKey[]> {
const { keys } = await this.resolveAccess(actorId);
return keys;
}
}
@@ -79,7 +96,7 @@ export class HqPermissionGuard implements CanActivate {
if (!user || user.actorType !== 'HQ') {
throw new ForbiddenException('需要 HQ 权限');
}
const keys = await this.resolver.resolveEffectiveKeys(user.actorId);
const { keys, isSuperAdmin } = await this.resolver.resolveAccess(user.actorId);
req.hqPermissionKeys = keys;
const required =
@@ -89,6 +106,7 @@ export class HqPermissionGuard implements CanActivate {
]) ?? [];
if (!required.length) return true;
if (isSuperAdmin) return true;
if (required.includes('__any_system_settings__')) {
if (!hasAnySystemSettingsPermission(keys)) {
throw new ForbiddenException('无系统设置权限');
@@ -142,7 +142,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
group: G.wechat_mini,
type: 'string',
requiresRestart: false,
placeholder: '3.4.14',
placeholder: '3.4.15',
description: 'semver 格式;客户端低于此版本时提示更新',
},
{
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { TestWhitelistService } from './test-whitelist.service';
@Global()
@Module({
providers: [TestWhitelistService],
exports: [TestWhitelistService],
})
export class TestWhitelistModule {}
@@ -0,0 +1,335 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.module';
import { serializeBigInt } from '../decorators/current-user.decorator';
export function normalizeTestPhone(phone: string | null | undefined): string {
return (phone || '').replace(/\D/g, '').trim();
}
export function assertMobilePhone(phone: string): string {
const p = normalizeTestPhone(phone);
if (!/^1\d{10}$/.test(p)) {
throw new BadRequestException(`手机号格式无效:${phone}`);
}
return p;
}
@Injectable()
export class TestWhitelistService {
constructor(private readonly prisma: PrismaService) {}
async isPhoneInWhitelist(phone: string | null | undefined): Promise<boolean> {
const p = normalizeTestPhone(phone);
if (!p) return false;
const row = await this.prisma.commonTestWhitelistPhone.findUnique({
where: { phone: p },
select: { id: true },
});
return !!row;
}
async assertGlobalWhitelistNotEmpty() {
const count = await this.prisma.commonTestWhitelistPhone.count();
if (count === 0) {
throw new BadRequestException('全局测试白名单为空,请先在「白名单管理」添加手机号');
}
}
async listPhones(query: { phone?: string; page?: number; pageSize?: number }) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonTestWhitelistPhoneWhereInput = {};
if (query.phone) {
where.phone = { contains: normalizeTestPhone(query.phone) || query.phone };
}
const [items, total] = await Promise.all([
this.prisma.commonTestWhitelistPhone.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonTestWhitelistPhone.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async addPhone(input: { phone: string; note?: string; createdByHqId?: bigint }) {
const phone = assertMobilePhone(input.phone);
const existing = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { phone } });
if (existing) {
throw new BadRequestException('该手机号已在白名单中');
}
const row = await this.prisma.commonTestWhitelistPhone.create({
data: {
phone,
note: input.note?.trim() || null,
createdByHqId: input.createdByHqId ?? null,
},
});
await this.syncTestFlagsForPhone(phone, true);
return serializeBigInt(row);
}
async updatePhone(id: bigint, input: { note?: string | null }) {
const row = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { id } });
if (!row) throw new NotFoundException('白名单记录不存在');
const updated = await this.prisma.commonTestWhitelistPhone.update({
where: { id },
data: { note: input.note === undefined ? undefined : input.note?.trim() || null },
});
return serializeBigInt(updated);
}
async removePhone(id: bigint) {
const row = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { id } });
if (!row) throw new NotFoundException('白名单记录不存在');
await this.prisma.commonTestWhitelistPhone.delete({ where: { id } });
await this.syncTestFlagsForPhone(row.phone, false);
return { ok: true };
}
/** 同步账号/门店 isTest,并回填订单/核销快照 */
async syncTestFlagsForPhone(phone: string, isTest: boolean) {
const p = normalizeTestPhone(phone);
if (!p) return;
await this.prisma.user.updateMany({ where: { phone: p }, data: { isTest } });
await this.prisma.storeAccount.updateMany({ where: { phone: p }, data: { isTest } });
await this.prisma.partnerAccount.updateMany({ where: { phone: p }, data: { isTest } });
if (isTest) {
await this.prisma.store.updateMany({ where: { phone: p }, data: { isTest: true } });
} else {
// 仅清除「联系电话命中且当前不在白名单」的自动标;手动标的门店若电话已不在名单则保持 isTest(运营可再关)
// 简化:电话命中且移出名单时置 false;手动标的非该电话门店不受影响
await this.prisma.store.updateMany({ where: { phone: p }, data: { isTest: false } });
}
const users = await this.prisma.user.findMany({
where: { phone: p },
select: { id: true },
});
const userIds = users.map((u) => u.id);
if (userIds.length) {
await this.prisma.order.updateMany({
where: { userId: { in: userIds } },
data: { isTest },
});
}
const stores = await this.prisma.store.findMany({
where: { phone: p },
select: { id: true },
});
const storeIds = stores.map((s) => s.id);
if (userIds.length || storeIds.length) {
const or: Prisma.RedeemRecordWhereInput[] = [];
if (userIds.length) or.push({ userId: { in: userIds } });
if (storeIds.length) or.push({ storeId: { in: storeIds } });
await this.prisma.redeemRecord.updateMany({
where: { OR: or },
data: { isTest },
});
}
}
async listAccounts(query: {
type: 'user' | 'store_account' | 'partner' | 'store' | 'order';
phone?: string;
page?: number;
pageSize?: number;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const phone = query.phone ? normalizeTestPhone(query.phone) : '';
if (query.type === 'user') {
const where: Prisma.UserWhereInput = { isTest: true };
if (phone) where.phone = { contains: phone };
const [items, total] = await Promise.all([
this.prisma.user.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
userNo: true,
phone: true,
nickname: true,
status: true,
createdAt: true,
isTest: true,
},
}),
this.prisma.user.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize, type: query.type });
}
if (query.type === 'store_account') {
const where: Prisma.StoreAccountWhereInput = { isTest: true };
if (phone) where.phone = { contains: phone };
const [items, total] = await Promise.all([
this.prisma.storeAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
phone: true,
name: true,
status: true,
createdAt: true,
isTest: true,
},
}),
this.prisma.storeAccount.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize, type: query.type });
}
if (query.type === 'partner') {
const where: Prisma.PartnerAccountWhereInput = { isTest: true };
if (phone) where.phone = { contains: phone };
const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
phone: true,
name: true,
companyName: true,
status: true,
createdAt: true,
isTest: true,
},
}),
this.prisma.partnerAccount.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize, type: query.type });
}
if (query.type === 'store') {
const where: Prisma.StoreWhereInput = { isTest: true };
if (phone) where.phone = { contains: phone };
const [items, total] = await Promise.all([
this.prisma.store.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
name: true,
phone: true,
status: true,
cityName: true,
createdAt: true,
isTest: true,
},
}),
this.prisma.store.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize, type: query.type });
}
const where: Prisma.OrderWhereInput = { isTest: true };
if (phone) {
where.OR = [
{ receiverPhone: { contains: phone } },
{ user: { phone: { contains: phone } } },
];
}
const [items, total] = await Promise.all([
this.prisma.order.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
orderNo: true,
status: true,
payStatus: true,
payAmount: true,
receiverPhone: true,
createdAt: true,
isTest: true,
user: { select: { id: true, phone: true, userNo: true } },
},
}),
this.prisma.order.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize, type: query.type });
}
async linkedForPhoneId(id: bigint) {
const row = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { id } });
if (!row) throw new NotFoundException('白名单记录不存在');
const phone = row.phone;
const [users, storeAccounts, partners, stores] = await Promise.all([
this.prisma.user.findMany({
where: { phone },
select: { id: true, userNo: true, phone: true, nickname: true, isTest: true, status: true },
}),
this.prisma.storeAccount.findMany({
where: { phone },
select: { id: true, phone: true, name: true, isTest: true, status: true },
}),
this.prisma.partnerAccount.findMany({
where: { phone },
select: { id: true, phone: true, name: true, companyName: true, isTest: true, status: true },
}),
this.prisma.store.findMany({
where: { phone },
select: { id: true, name: true, phone: true, isTest: true, status: true },
}),
]);
return serializeBigInt({
phone: row,
users,
storeAccounts,
partners,
stores,
});
}
/** 从旧商品/门店可见性子表导入全局名单(幂等) */
async migrateVisibilityPhones(createdByHqId?: bigint) {
const [productPhones, storePhones] = await Promise.all([
this.prisma.commonProductVisibilityPhone.findMany({ select: { phone: true } }),
this.prisma.storeVisibilityPhone.findMany({ select: { phone: true } }),
]);
const set = new Set<string>();
for (const row of [...productPhones, ...storePhones]) {
const p = normalizeTestPhone(row.phone);
if (/^1\d{10}$/.test(p)) set.add(p);
}
let added = 0;
for (const phone of set) {
const exists = await this.prisma.commonTestWhitelistPhone.findUnique({ where: { phone } });
if (exists) {
await this.syncTestFlagsForPhone(phone, true);
continue;
}
await this.prisma.commonTestWhitelistPhone.create({
data: {
phone,
note: '自可见性白名单迁移',
createdByHqId: createdByHqId ?? null,
},
});
await this.syncTestFlagsForPhone(phone, true);
added += 1;
}
return { importedCandidates: set.size, added };
}
}
@@ -0,0 +1,109 @@
import { parse as parseQueryString } from 'node:querystring';
/**
* 小飞侠路由回调实际推送多为 multipart/form-data(非 JSON / urlencoded)。
* 解析失败时保留 _rawText,便于第三方日志排查。
*/
export function parseCourierCallbackBody(
rawText: string,
contentType: string,
): Record<string, unknown> {
const ct = contentType.toLowerCase();
const trimmed = rawText.trim();
if (!trimmed) return {};
if (ct.includes('multipart/form-data') || looksLikeMultipart(trimmed)) {
const multipart = parseMultipartFormData(rawText, contentType);
if (multipart && Object.keys(multipart).length > 0) return multipart;
}
if (ct.includes('application/json') || trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed) as unknown;
return parsed && typeof parsed === 'object'
? (parsed as Record<string, unknown>)
: { value: parsed };
} catch {
return { _rawText: rawText.slice(0, 4000) };
}
}
if (ct.includes('application/x-www-form-urlencoded') || looksLikeUrlEncoded(trimmed)) {
return flattenQueryValues(parseQueryString(trimmed));
}
return { _rawText: rawText.slice(0, 4000) };
}
function looksLikeMultipart(raw: string): boolean {
return raw.startsWith('--') && /Content-Disposition:\s*form-data/i.test(raw);
}
function looksLikeUrlEncoded(raw: string): boolean {
if (looksLikeMultipart(raw)) return false;
return /^[^=&\s]+=/.test(raw) && !raw.includes('\n');
}
function flattenQueryValues(
parsed: Record<string, string | string[] | undefined>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(parsed)) {
if (Array.isArray(value)) out[key] = value[value.length - 1];
else if (value !== undefined) out[key] = value;
}
return out;
}
/** 仅解析文本字段(小飞侠回调无文件) */
export function parseMultipartFormData(
rawText: string,
contentType: string,
): Record<string, unknown> | null {
const boundary = extractBoundary(contentType, rawText);
if (!boundary) return null;
const delimiter = `--${boundary}`;
const parts = rawText.split(delimiter);
const result: Record<string, unknown> = {};
for (const part of parts) {
const trimmedPart = part.replace(/^\r?\n/, '').replace(/\r?\n$/, '');
if (!trimmedPart || trimmedPart === '--' || trimmedPart.startsWith('--')) continue;
let headers = '';
let body = '';
const crlfIdx = trimmedPart.indexOf('\r\n\r\n');
const lfIdx = trimmedPart.indexOf('\n\n');
if (crlfIdx >= 0 && (lfIdx < 0 || crlfIdx <= lfIdx)) {
headers = trimmedPart.slice(0, crlfIdx);
body = trimmedPart.slice(crlfIdx + 4);
} else if (lfIdx >= 0) {
headers = trimmedPart.slice(0, lfIdx);
body = trimmedPart.slice(lfIdx + 2);
} else {
continue;
}
body = body.replace(/\r?\n$/, '');
const nameMatch = /Content-Disposition:[^\r\n]*;\s*name="([^"]+)"/i.exec(headers);
if (!nameMatch?.[1]) continue;
if (/filename=/i.test(headers)) continue;
result[nameMatch[1]] = body;
}
return Object.keys(result).length > 0 ? result : null;
}
function extractBoundary(contentType: string, rawText: string): string | null {
const fromHeader = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
if (fromHeader?.[1] || fromHeader?.[2]) {
return (fromHeader[1] || fromHeader[2] || '').trim() || null;
}
const firstLine = rawText.split(/\r?\n/, 1)[0]?.trim() ?? '';
if (firstLine.startsWith('--') && firstLine.length > 2) {
return firstLine.slice(2).replace(/--$/, '') || null;
}
return null;
}
@@ -139,20 +139,15 @@ export class XiaofeixiaProvider implements ICourierProvider {
parseTrackCallback(body: unknown): TrackCallbackPayload | null {
if (!body || typeof body !== 'object') return null;
const root = body as Record<string, unknown>;
// 兼容顶层字段或 data/payload 包裹
const nested =
root.data && typeof root.data === 'object'
? (root.data as Record<string, unknown>)
: root.payload && typeof root.payload === 'object'
? (root.payload as Record<string, unknown>)
: null;
const raw = nested ? { ...nested, ...root } : root;
const outNumber = String(raw.outNumber ?? '').trim();
const trackingNumber = String(raw.number ?? raw.trackingNumber ?? '').trim();
const status = String(raw.status ?? '').trim();
const statusName = String(raw.statusName ?? '').trim();
const trackInfo = String(raw.trackInfo ?? '').trim();
const createTime = String(raw.createTime ?? '').trim();
// 兼容顶层字段或 data/payload 包裹;data 若为 JSON 字符串也解开
const nestedObj = this.unwrapNestedObject(root.data) ?? this.unwrapNestedObject(root.payload);
const raw = nestedObj ? { ...nestedObj, ...root } : root;
const outNumber = this.firstString(raw, ['outNumber', 'out_number', 'outNo']);
const trackingNumber = this.firstString(raw, ['number', 'trackingNumber', 'trackingNo']);
const status = this.firstString(raw, ['status']);
const statusName = this.firstString(raw, ['statusName', 'status_name']);
const trackInfo = this.firstString(raw, ['trackInfo', 'track_info']);
const createTime = this.firstString(raw, ['createTime', 'create_time']);
if (!outNumber && !trackingNumber) return null;
if (!status && !trackInfo) return null;
return {
@@ -165,6 +160,38 @@ export class XiaofeixiaProvider implements ICourierProvider {
};
}
private unwrapNestedObject(value: unknown): Record<string, unknown> | null {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
if (typeof value === 'string' && value.trim().startsWith('{')) {
try {
const parsed = JSON.parse(value) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
return null;
}
}
return null;
}
private firstString(raw: Record<string, unknown>, keys: string[]): string {
for (const key of keys) {
const value = raw[key];
if (value == null) continue;
if (Array.isArray(value)) {
const last = value[value.length - 1];
if (last != null && String(last).trim()) return String(last).trim();
continue;
}
const text = String(value).trim();
if (text) return text;
}
return '';
}
mapTrackStatus(status: string, statusName?: string): CourierMappedOrderStatus | null {
const name = (statusName || '').trim();
if (status === '5' || /签收/.test(name)) {
+46 -4
View File
@@ -3,7 +3,7 @@ import { NestFactory } from '@nestjs/core';
import { loadAppConfig } from '@dukang/shared-types';
import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common';
import { json, urlencoded } from 'express';
import { json, urlencoded, type NextFunction, type Request, type Response } from 'express';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
@@ -11,6 +11,36 @@ import { LoggingInterceptor } from './common/logging/logging.interceptor';
import { preloadSystemConfigEnv } from './common/system-config/system-config.env';
import { AlertService } from './common/alert/alert.service';
import { initSentryIfConfigured } from './integrations/sentry/sentry.bootstrap';
import { parseCourierCallbackBody } from './integrations/courier/xiaofeixia/xiaofeixia-callback-body';
function isCourierTrackCallbackUrl(url?: string): boolean {
if (!url) return false;
const path = url.split('?')[0] ?? '';
return (
(path.includes('/callbacks/courier/') && path.endsWith('/track')) ||
path.endsWith('/callbacks/delivery/track')
);
}
/** 小飞侠回调:无论 Content-Type,先吃下 rawBody 再尽力解析(含 multipart/form-data */
function courierTrackRawBodyMiddleware(req: Request, _res: Response, next: NextFunction) {
if (req.method !== 'POST' || !isCourierTrackCallbackUrl(req.originalUrl || req.url)) {
return next();
}
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
req.on('error', (err) => next(err));
req.on('end', () => {
const buf = Buffer.concat(chunks);
const rawText = buf.toString('utf8');
(req as Request & { rawBody?: Buffer }).rawBody = buf;
req.body = parseCourierCallbackBody(rawText, String(req.headers['content-type'] || ''));
next();
});
}
async function bootstrap() {
const preloaded = await preloadSystemConfigEnv().catch((e) => {
@@ -26,9 +56,14 @@ async function bootstrap() {
app.setGlobalPrefix('api/v1');
app.set('trust proxy', true);
app.enableCors({ origin: true, credentials: true });
// 微信支付等需 rawBody;小飞侠回调多为 x-www-form-urlencoded
// 小飞侠路由回调:优先捕获 rawBody(Content-Type 异常时也能入第三方日志)
app.use(courierTrackRawBodyMiddleware);
// 微信支付等需 rawBody;其它 JSON / form 请求走常规解析(跳过已由上面吃掉 body 的小飞侠回调)
app.use(
json({
type: (req) =>
!isCourierTrackCallbackUrl((req as Request).originalUrl || req.url) &&
Boolean(req.headers['content-type']?.includes('json')),
verify: (req, _res, buf) => {
if (
req.url?.includes('/callbacks/wechat/pay') ||
@@ -40,11 +75,18 @@ async function bootstrap() {
},
}),
);
app.use(urlencoded({ extended: true }));
app.use(
urlencoded({
extended: true,
type: (req) =>
!isCourierTrackCallbackUrl((req as Request).originalUrl || req.url) &&
Boolean(req.headers['content-type']?.includes('urlencoded')),
}),
);
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new HttpExceptionFilter(app.get(AlertService)));
app.useGlobalInterceptors(new ResponseInterceptor(), app.get(LoggingInterceptor));
const port = process.env.PORT || 3000;
const port = process.env.PORT || 3010;
const cfg = loadAppConfig();
const smsMode = cfg.mockSms ? 'MOCK' : 'ALIYUN';
console.log(`[config] NODE_ENV=${process.env.NODE_ENV} MOCK_SMS=${cfg.mockSms} SMS=${smsMode}`);
@@ -1,6 +1,10 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import {
TestWhitelistService,
normalizeTestPhone,
} from '../../common/test-whitelist/test-whitelist.service';
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
export type CatalogViewer = {
@@ -10,13 +14,32 @@ export type CatalogViewer = {
bypassWhitelist?: boolean;
};
function normalizePhone(phone: string | null | undefined): string {
return (phone || '').replace(/\D/g, '').trim();
}
@Injectable()
export class CatalogService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly testWhitelist: TestWhitelistService,
) {}
private async whitelistPhoneSet(): Promise<Set<string>> {
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
select: { phone: true },
});
return new Set(rows.map((r) => normalizeTestPhone(r.phone)).filter(Boolean));
}
isVisibleToViewer(
product: { visibilityWhitelistEnabled: boolean },
viewer?: CatalogViewer,
whitelistPhones?: Set<string>,
): boolean {
if (viewer?.bypassWhitelist) return true;
if (!product.visibilityWhitelistEnabled) return true;
const phone = normalizeTestPhone(viewer?.phone);
if (!phone) return false;
if (whitelistPhones) return whitelistPhones.has(phone);
return false;
}
async listCities() {
const cities = await this.prisma.commonCity.findMany({
@@ -58,11 +81,13 @@ export class CatalogService {
orderBy: { sortOrder: 'asc' },
include: {
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
});
const visible = products.filter((p) => this.isVisibleToViewer(p, viewer));
const whitelistPhones = products.some((p) => p.visibilityWhitelistEnabled)
? await this.whitelistPhoneSet()
: new Set<string>();
const visible = products.filter((p) => this.isVisibleToViewer(p, viewer, whitelistPhones));
const productIds = visible.map((p) => p.id);
const resources = productIds.length
@@ -81,7 +106,7 @@ export class CatalogService {
return serializeBigInt(
visible.map((p) => {
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = p;
const { visibilityWhitelistEnabled: _wl, ...rest } = p;
return {
...rest,
benefitAmount: p.benefitAmount ?? p.price,
@@ -98,11 +123,13 @@ export class CatalogService {
where: { id },
include: {
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
});
if (!product) return null;
if (!this.isVisibleToViewer(product, viewer)) {
const whitelistPhones = product.visibilityWhitelistEnabled
? await this.whitelistPhoneSet()
: new Set<string>();
if (!this.isVisibleToViewer(product, viewer, whitelistPhones)) {
return null;
}
@@ -117,7 +144,7 @@ export class CatalogService {
});
const media = mapProductMedia(product, resources);
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = product;
const { visibilityWhitelistEnabled: _wl, ...rest } = product;
return serializeBigInt({
...rest,
benefitAmount: product.benefitAmount ?? product.price,
@@ -126,18 +153,20 @@ export class CatalogService {
});
}
/** 下单前校验:白名单商品仅白名单手机号可买 */
/** 下单前校验:白名单商品仅全局测试白名单手机号可买 */
async assertPurchasable(productId: bigint, viewerPhone?: string | null) {
const product = await this.prisma.commonProductItem.findUnique({
where: { id: productId },
include: { visibilityPhones: { select: { phone: true } } },
});
if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
if (!this.isVisibleToViewer(product, { phone: viewerPhone })) {
if (product.visibilityWhitelistEnabled) {
const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone);
if (!ok) {
throw new BadRequestException('该商品暂不对当前账号开放');
}
}
return product;
}
@@ -148,18 +177,4 @@ export class CatalogService {
});
return user?.phone ?? null;
}
isVisibleToViewer(
product: {
visibilityWhitelistEnabled: boolean;
visibilityPhones: Array<{ phone: string }>;
},
viewer?: CatalogViewer,
): boolean {
if (viewer?.bypassWhitelist) return true;
if (!product.visibilityWhitelistEnabled) return true;
const phone = normalizePhone(viewer?.phone);
if (!phone) return false;
return product.visibilityPhones.some((row) => normalizePhone(row.phone) === phone);
}
}
@@ -83,18 +83,13 @@ export class WechatController {
return this.wechat.createJssdkConfig(pageUrl, actorRef);
}
/**
* 仅去 hash。勿剔除 code/state
* iOS 微信用「document 入场 URL」验签,OAuth 回跳页的 query 必须原样参与签名。
*/
private normalizeJssdkUrl(rawUrl: string): string {
try {
const parsed = new URL(rawUrl);
parsed.hash = '';
parsed.searchParams.delete('code');
parsed.searchParams.delete('state');
const query = parsed.searchParams.toString();
return `${parsed.origin}${parsed.pathname}${query ? `?${query}` : ''}`;
} catch {
return rawUrl.split('#')[0];
}
}
@Get('oauth-url')
oauthUrl(
@@ -25,6 +25,7 @@ import { AnalyticsService } from '../analytics/analytics.service';
import { UserAddressService } from './user-address.service';
import { ResourceService } from '../common/resource.service';
import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
import type { User } from '@prisma/client';
@@ -67,8 +68,21 @@ export class AuthService {
private readonly userAddressService: UserAddressService,
@Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService,
private readonly hqPermissions: HqPermissionsResolver,
private readonly testWhitelist: TestWhitelistService,
) {}
/** 登录/绑号后按全局白名单同步 isTest */
private async syncTestFlagByPhone(phone: string) {
const isTest = await this.testWhitelist.isPhoneInWhitelist(phone);
await Promise.all([
this.prisma.user.updateMany({ where: { phone }, data: { isTest } }),
this.prisma.storeAccount.updateMany({ where: { phone }, data: { isTest } }),
this.prisma.partnerAccount.updateMany({ where: { phone }, data: { isTest } }),
this.prisma.store.updateMany({ where: { phone }, data: { isTest } }),
]);
return isTest;
}
private assertMobilePhone(phone: string) {
const trimmed = phone.trim();
if (!/^1[3-9]\d{9}$/.test(trimmed)) {
@@ -375,12 +389,14 @@ export class AuthService {
});
if (!user) {
const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
user = await this.prisma.user.create({
data: {
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${normalizedPhone.slice(-4)}`,
isTest,
sourceType: source?.sourceType ?? 'ORGANIC',
sourceRefId: source?.sourceRefId,
sourceLabel: source?.sourceLabel,
@@ -401,6 +417,11 @@ export class AuthService {
include: { avatar: true },
});
}
await this.syncTestFlagByPhone(normalizedPhone);
user = await this.prisma.user.findUniqueOrThrow({
where: { id: user.id },
include: { avatar: true },
});
await this.assertActiveUser(user.id);
}
@@ -810,12 +831,14 @@ export class AuthService {
}
}
if (!user) {
const isTest = await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
user = await this.prisma.user.create({
data: {
phone: normalizedPhone,
phoneVerifiedAt: new Date(),
userNo: generateUserNo(),
nickname: `用户${normalizedPhone.slice(-4)}`,
isTest,
cityPreference: {
create: {
selectedCityCode: '410100',
@@ -852,6 +875,12 @@ export class AuthService {
if (!user) throw new BadRequestException('登录失败');
await this.syncTestFlagByPhone(normalizedPhone);
user = await this.prisma.user.findUniqueOrThrow({
where: { id: user.id },
include: { avatar: true },
});
this.analyticsService.trackOneSafe(user.id, clientApp, {
eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login',
extraJson: { method },
@@ -998,6 +1027,7 @@ export class AuthService {
if (!account) throw new BadRequestException('该手机号未绑定门店');
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
if (!account.bindings.length) throw new BadRequestException('该账号未绑定任何门店');
await this.syncTestFlagByPhone(normalizedPhone);
await this.prisma.storeAccount.update({
where: { id: account.id },
data: { lastLoginAt: new Date() },
@@ -1032,6 +1062,7 @@ export class AuthService {
});
if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
await this.syncTestFlagByPhone(normalizedPhone);
const primary = await this.resolvePrimaryAccount(account.id);
await this.prisma.partnerAccount.update({
where: { id: account.id },
@@ -1,11 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
HQ_DANGEROUS_PERMISSION_KEYS,
HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS,
LEGACY_SYSTEM_SETTINGS_KEY,
expandHqPermissionKeys,
hqBasePermissionKeys,
type HqPermissionKey,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
@@ -83,10 +81,9 @@ export class AdminHqPermissionsService {
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
if (account.adminRole === 'SUPER_ADMIN') {
const rolePermissionKeys = [
...hqBasePermissionKeys(),
...HQ_DANGEROUS_PERMISSION_KEYS,
] as HqPermissionKey[];
const rolePermissionKeys = HQ_PERMISSION_CATALOG.map(
(p) => p.key,
) as HqPermissionKey[];
const effectivePermissionKeys = [
...new Set([...rolePermissionKeys, ...userPermissionKeys]),
] as HqPermissionKey[];
@@ -38,6 +38,12 @@ export class AdminOrdersController {
return this.ordersService.getShipDefaults();
}
/** 物流路由(小飞侠实时轨迹,对齐 C 端 GET /trade/orders/:id/track */
@Get(':id/track')
track(@Param('id') id: string) {
return this.ordersService.getOrderTrack(BigInt(id));
}
@Get(':id')
detail(@Param('id') id: string) {
return this.ordersService.detail(BigInt(id));
@@ -43,6 +43,7 @@ export class AdminOrdersService {
if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
if (query.createdTo) where.createdAt.lte = new Date(query.createdTo);
}
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.order.findMany({
@@ -63,6 +64,15 @@ export class AdminOrdersService {
return serializeBigInt({ items, total, page, pageSize });
}
async getOrderTrack(id: bigint) {
const order = await this.prisma.order.findUnique({
where: { id },
select: { id: true },
});
if (!order) throw new NotFoundException('订单不存在');
return serializeBigInt(await this.fulfillmentService.getOrderTrack(id));
}
async detail(id: bigint) {
const order = await this.prisma.order.findUnique({
where: { id },
@@ -42,6 +42,7 @@ export class AdminPartnersService {
if (query.phone) where.phone = { contains: query.phone };
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.id = BigInt(query.partnerId);
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({
@@ -5,6 +5,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper';
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
function normalizePhones(phones?: string[]): string[] {
if (!phones?.length) return [];
@@ -57,7 +58,10 @@ function resolveFulfillmentFlags(input: {
@Injectable()
export class AdminProductsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly testWhitelist: TestWhitelistService,
) {}
async list(query: AdminProductsQueryDto) {
const page = query.page ?? 1;
@@ -140,6 +144,11 @@ export class AdminProductsService {
const phones = normalizePhones(dto.visibilityPhones);
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
if (whitelistEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
// 手机号统一在「白名单管理」维护;此处忽略分实体 phones(兼容旧客户端传参)
void phones;
const product = await this.createWithGeneratedSku({
barcode69: dto.barcode69,
@@ -158,13 +167,6 @@ export class AdminProductsService {
...(dto.detailContent !== undefined
? { detailContent: dto.detailContent as Prisma.InputJsonValue }
: {}),
...(phones.length
? {
visibilityPhones: {
create: phones.map((phone) => ({ phone })),
},
}
: {}),
});
if (dto.coverUrl) {
@@ -225,9 +227,10 @@ export class AdminProductsService {
},
});
if (dto.visibilityPhones !== undefined) {
await this.syncVisibilityPhones(id, normalizePhones(dto.visibilityPhones));
if (dto.visibilityWhitelistEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
// 分实体手机号已废弃;忽略 dto.visibilityPhones
if (dto.coverUrl) {
await this.syncCover(id, dto.coverUrl);
@@ -42,6 +42,7 @@ export class AdminRedeemService {
if (query.channel === 'SCAN' || query.channel === 'PHONE') {
where.channel = query.channel;
}
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.redeemRecord.findMany({
@@ -17,6 +17,7 @@ import type {
UpdateStoreMediaDto,
UpdateStoreStatusDto,
} from './dto/admin-mutate.dto';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
function normalizeStoreOptionalText(value: unknown): string | null {
@@ -51,6 +52,7 @@ export class AdminStoresService {
private readonly partnerCityService: PartnerCityService,
private readonly storeCategoryService: StoreCategoryService,
private readonly analyticsService: AnalyticsService,
private readonly testWhitelist: TestWhitelistService,
) {}
async listStores(query: AdminStoresQueryDto) {
@@ -65,11 +67,12 @@ export class AdminStoresService {
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
if (query.phone) where.phone = { contains: query.phone };
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.store.findMany({
where,
orderBy: { createdAt: 'desc' },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
include: {
@@ -283,18 +286,7 @@ export class AdminStoresService {
? !!dto.visibilityWhitelistEnabled
: current.visibilityWhitelistEnabled;
if (nextEnabled) {
const phones =
dto.visibilityPhones !== undefined
? normalizeVisibilityPhones(dto.visibilityPhones)
: (
await this.prisma.storeVisibilityPhone.findMany({
where: { storeId: id },
select: { phone: true },
})
).map((p) => p.phone);
if (!phones.length) {
throw new BadRequestException('开启白名单时请至少添加一个手机号');
}
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
}
const bankTouched =
@@ -372,25 +364,34 @@ export class AdminStoresService {
...(dto.visibilityWhitelistEnabled !== undefined
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
: {}),
...(dto.isTest !== undefined ? { isTest: !!dto.isTest } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: Math.floor(Number(dto.sortOrder)) || 0 } : {}),
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
},
});
if (dto.visibilityPhones !== undefined) {
const phones = normalizeVisibilityPhones(dto.visibilityPhones);
await tx.storeVisibilityPhone.deleteMany({ where: { storeId: id } });
if (phones.length) {
await tx.storeVisibilityPhone.createMany({
data: phones.map((phone) => ({ storeId: id, phone })),
});
}
// 分实体手机号已废弃,忽略写入
}
if (dto.coverUrl) {
if (dto.coverUrl !== undefined) {
const coverUrl = dto.coverUrl?.trim() || '';
if (!coverUrl) {
if (current.coverResourceId) {
await tx.commonResource.update({
where: { id: current.coverResourceId },
data: { url: dto.coverUrl, ossKey: dto.coverUrl },
data: { status: 'DELETED' },
});
await tx.store.update({ where: { id }, data: { coverResourceId: null } });
}
await tx.commonResource.updateMany({
where: { ownerType: 'STORE', ownerId: id, bizType: 'COVER', status: 'ACTIVE' },
data: { status: 'DELETED' },
});
} else if (current.coverResourceId) {
await tx.commonResource.update({
where: { id: current.coverResourceId },
data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' },
});
} else {
const cover = await tx.commonResource.create({
@@ -400,14 +401,61 @@ export class AdminStoresService {
bizType: 'COVER',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: dto.coverUrl,
url: dto.coverUrl,
ossKey: coverUrl,
url: coverUrl,
},
});
await tx.store.update({ where: { id }, data: { coverResourceId: cover.id } });
}
}
if (dto.envPhotoUrls !== undefined) {
const envUrls = [...new Set(
(dto.envPhotoUrls ?? []).map((u) => String(u ?? '').trim()).filter(Boolean),
)].slice(0, 20);
await tx.commonResource.updateMany({
where: { ownerType: 'STORE', ownerId: id, bizType: 'ENV', status: 'ACTIVE' },
data: { status: 'DELETED' },
});
for (let i = 0; i < envUrls.length; i++) {
await tx.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: id,
bizType: 'ENV',
mediaType: 'IMAGE',
ossBucket: 'legacy',
ossKey: envUrls[i],
url: envUrls[i],
sortOrder: i,
},
});
}
}
if (dto.contractUrl !== undefined) {
const contractUrl = dto.contractUrl?.trim() || '';
await tx.commonResource.updateMany({
where: { ownerType: 'STORE', ownerId: id, bizType: 'CONTRACT', status: 'ACTIVE' },
data: { status: 'DELETED' },
});
if (contractUrl) {
const isPdf = /\.pdf(\?|$)/i.test(contractUrl);
await tx.commonResource.create({
data: {
ownerType: 'STORE',
ownerId: id,
bizType: 'CONTRACT',
mediaType: isPdf ? 'FILE' : 'IMAGE',
ossBucket: 'legacy',
ossKey: contractUrl,
url: contractUrl,
sortOrder: 0,
},
});
}
}
if (primaryBinding?.storeAccount) {
const account = primaryBinding.storeAccount;
const accountData: {
@@ -505,11 +553,14 @@ export class AdminStoresService {
throw new BadRequestException('好客权益券使用规则最多 1000 字');
}
const visibilityPhones = normalizeVisibilityPhones(dto.visibilityPhones);
const whitelistEnabled = !!dto.visibilityWhitelistEnabled;
if (whitelistEnabled && !visibilityPhones.length) {
throw new BadRequestException('开启白名单时请至少添加一个手机号');
if (whitelistEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
const isTest =
dto.isTest !== undefined
? !!dto.isTest
: await this.testWhitelist.isPhoneInWhitelist(normalizedPhone);
const store = await this.prisma.store.create({
data: {
@@ -531,18 +582,13 @@ export class AdminStoresService {
openTime2: openTime2 || null,
closeTime2: closeTime2 || null,
visibilityWhitelistEnabled: whitelistEnabled,
isTest,
sortOrder: dto.sortOrder != null ? Math.floor(Number(dto.sortOrder)) || 0 : 0,
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
status: 'OPEN',
auditStatus: 'APPROVED',
auditedAt: new Date(),
rejectReason: null,
...(visibilityPhones.length
? {
visibilityPhones: {
create: visibilityPhones.map((phone) => ({ phone })),
},
}
: {}),
},
});
@@ -561,7 +607,7 @@ export class AdminStoresService {
await this.prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
}
const envUrls = (dto.envPhotoUrls ?? []).filter(Boolean);
const envUrls = [...new Set((dto.envPhotoUrls ?? []).map((u) => String(u ?? '').trim()).filter(Boolean))].slice(0, 20);
for (let i = 0; i < envUrls.length; i++) {
await this.prisma.commonResource.create({
data: {
@@ -746,6 +792,7 @@ export class AdminStoresService {
where.bindings = { some: { storeId: BigInt(query.storeId) } };
}
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.storeAccount.findMany({
@@ -0,0 +1,167 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
import { SystemConfigService } from '../../common/system-config/system-config.service';
class ListPhonesQueryDto {
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number = 20;
}
class AddPhoneDto {
@IsString()
phone!: string;
@IsOptional()
@IsString()
@MaxLength(256)
note?: string;
}
class UpdatePhoneDto {
@IsOptional()
@IsString()
@MaxLength(256)
note?: string | null;
}
class ListAccountsQueryDto {
@IsIn(['user', 'store_account', 'partner', 'store', 'order'])
type!: 'user' | 'store_account' | 'partner' | 'store' | 'order';
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number = 20;
}
class UpdateMockFlagsDto {
@IsOptional()
@IsBoolean()
mockSms?: boolean;
@IsOptional()
@IsBoolean()
mockWechat?: boolean;
@IsOptional()
@IsBoolean()
mockPay?: boolean;
}
@Controller('admin/test-whitelist')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('test_whitelist')
export class AdminTestWhitelistController {
constructor(
private readonly testWhitelist: TestWhitelistService,
private readonly systemConfig: SystemConfigService,
) {}
@Get('mock-flags')
async getMockFlags() {
const form = await this.systemConfig.getForm(['feature']);
const v = form.values;
return {
mockSms: v.MOCK_SMS === 'true' || v.MOCK_SMS === '1',
mockWechat: v.MOCK_WECHAT === 'true' || v.MOCK_WECHAT === '1',
mockPay: v.MOCK_PAY === 'true' || v.MOCK_PAY === '1',
};
}
@Put('mock-flags')
async updateMockFlags(@Body() dto: UpdateMockFlagsDto) {
const values: Record<string, string> = {};
if (dto.mockSms !== undefined) values.MOCK_SMS = String(dto.mockSms);
if (dto.mockWechat !== undefined) values.MOCK_WECHAT = String(dto.mockWechat);
if (dto.mockPay !== undefined) values.MOCK_PAY = String(dto.mockPay);
if (Object.keys(values).length) {
await this.systemConfig.update({ values }, ['feature']);
}
return this.getMockFlags();
}
@Get('phones')
listPhones(@Query() query: ListPhonesQueryDto) {
return this.testWhitelist.listPhones(query);
}
@Post('phones')
addPhone(@Body() dto: AddPhoneDto, @CurrentUser() actor: AuthUser) {
return this.testWhitelist.addPhone({
phone: dto.phone,
note: dto.note,
createdByHqId: actor.actorId,
});
}
@Patch('phones/:id')
updatePhone(@Param('id') id: string, @Body() dto: UpdatePhoneDto) {
return this.testWhitelist.updatePhone(BigInt(id), { note: dto.note });
}
@Delete('phones/:id')
removePhone(@Param('id') id: string) {
return this.testWhitelist.removePhone(BigInt(id));
}
@Get('accounts')
listAccounts(@Query() query: ListAccountsQueryDto) {
return this.testWhitelist.listAccounts(query);
}
@Get('phones/:id/linked')
linked(@Param('id') id: string) {
return this.testWhitelist.linkedForPhoneId(BigInt(id));
}
@Post('migrate-visibility')
migrate(@CurrentUser() actor: AuthUser) {
return this.testWhitelist.migrateVisibilityPhones(actor.actorId);
}
}
@@ -19,6 +19,7 @@ function mapAdminUserRow(u: {
sourceType: string;
sourceRefId: bigint | null;
sourceLabel: string | null;
isTest?: boolean;
createdAt: Date;
updatedAt: Date;
_count: { orders: number };
@@ -37,6 +38,7 @@ function mapAdminUserRow(u: {
sourceType: u.sourceType,
sourceRefId: u.sourceRefId,
sourceLabel: u.sourceLabel,
isTest: !!u.isTest,
createdAt: u.createdAt,
updatedAt: u.updatedAt,
orderCount: u._count.orders,
@@ -58,6 +60,7 @@ export class AdminUsersService {
if (query.status !== undefined) where.status = query.status;
if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null };
if (query.phoneVerified === '0') where.phoneVerifiedAt = null;
if (query.excludeTest) where.isTest = false;
const [items, total] = await Promise.all([
this.prisma.user.findMany({
@@ -78,6 +81,7 @@ export class AdminUsersService {
sourceType: true,
sourceRefId: true,
sourceLabel: true,
isTest: true,
createdAt: true,
updatedAt: true,
_count: { select: { orders: true } },
@@ -1,5 +1,6 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
@@ -96,6 +97,7 @@ export class CreateStoreDto {
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(20)
envPhotoUrls?: string[];
@IsOptional()
@@ -128,16 +130,27 @@ export class CreateStoreDto {
@Min(0)
avgPrice?: number;
/** 开启后仅白名单手机号在 C 端可见 */
/** 开启后仅全局测试白名单手机号在 C 端可见 */
@IsOptional()
@IsBoolean()
visibilityWhitelistEnabled?: boolean;
/** 可见白名单手机号列表 */
/** @deprecated 已并入全局白名单,忽略 */
@IsOptional()
@IsArray()
@IsString({ each: true })
visibilityPhones?: string[];
/** 测试门店标记 */
@IsOptional()
@IsBoolean()
isTest?: boolean;
/** 列表排序,越小越靠前 */
@IsOptional()
@IsNumber()
@Min(0)
sortOrder?: number;
}
export class UpdateStoreDto {
@@ -159,7 +172,17 @@ export class UpdateStoreDto {
@IsOptional()
@IsString()
coverUrl?: string;
coverUrl?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(20)
envPhotoUrls?: string[];
@IsOptional()
@IsString()
contractUrl?: string | null;
@IsOptional()
@IsString()
@@ -227,16 +250,27 @@ export class UpdateStoreDto {
@IsString()
bankBranch?: string | null;
/** 开启后仅白名单手机号在 C 端可见 */
/** 开启后仅全局测试白名单手机号在 C 端可见 */
@IsOptional()
@IsBoolean()
visibilityWhitelistEnabled?: boolean;
/** 可见白名单手机号列表 */
/** @deprecated 已并入全局白名单,忽略 */
@IsOptional()
@IsArray()
@IsString({ each: true })
visibilityPhones?: string[];
/** 测试门店标记 */
@IsOptional()
@IsBoolean()
isTest?: boolean;
/** 列表排序,越小越靠前 */
@IsOptional()
@IsNumber()
@Min(0)
sortOrder?: number;
}
export class CreateStoreAccountDto {
@@ -1,5 +1,12 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type, Transform } from 'class-transformer';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
function toOptionalBoolean(value: unknown): boolean | undefined {
if (value === undefined || value === null || value === '') return undefined;
if (value === true || value === 'true' || value === '1' || value === 1) return true;
if (value === false || value === 'false' || value === '0' || value === 0) return false;
return undefined;
}
export class PaginationQueryDto {
@IsOptional()
@@ -37,6 +44,12 @@ export class AdminUsersQueryDto extends PaginationQueryDto {
@Type(() => Number)
@IsIn([0, 1])
status?: number;
/** 勾选「过滤测试账号」时传 true */
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
export class AdminOrdersQueryDto extends PaginationQueryDto {
@@ -75,6 +88,11 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
createdTo?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
/** 概览页用户/订单 ECharts 聚合筛选 */
@@ -128,6 +146,11 @@ export class AdminStoresQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
auditStatus?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
@@ -142,6 +165,11 @@ export class AdminStoreAccountsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
export class AdminPartnersQueryDto extends PaginationQueryDto {
@@ -164,6 +192,11 @@ export class AdminPartnersQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
partnerId?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
export class AdminCityWarehousesQueryDto extends PaginationQueryDto {
@@ -247,6 +280,11 @@ export class AdminRedeemRecordsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
channel?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
}
export class AdminStoreRatingsQueryDto extends PaginationQueryDto {
@@ -80,6 +80,7 @@ import { AdminDevPlanController } from '../dev-plan/admin-dev-plan.controller';
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
import { AdminDomainEventsController } from './admin-domain-events.controller';
import { AdminDomainEventsService } from './admin-domain-events.service';
import { AdminTestWhitelistController } from './admin-test-whitelist.controller';
@Module({
imports: [CityScopeModule, IamModule, TradeModule, AnalyticsModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule, DevPlanModule],
@@ -128,6 +129,7 @@ import { AdminDomainEventsService } from './admin-domain-events.service';
AdminKnowledgeBasesController,
AdminDevPlanController,
AdminFulfillmentProvidersController,
AdminTestWhitelistController,
],
providers: [
AdminDashboardService,
@@ -180,6 +180,15 @@ export class RedeemService {
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
const [userRow, storeRow] = await Promise.all([
this.prisma.user.findUnique({ where: { id: userId }, select: { isTest: true } }),
this.prisma.store.findUnique({
where: { id: account.storeId },
select: { isTest: true },
}),
]);
const isTest = !!(userRow?.isTest || storeRow?.isTest);
let record;
try {
record = await this.prisma.$transaction(async (tx) => {
@@ -194,6 +203,7 @@ export class RedeemService {
amount,
settleAmount,
channel: redeemChannel,
isTest,
allocations: {
create: normalizedAllocations.map((item, index) => ({
couponId: BigInt(item.couponId),
@@ -204,6 +214,7 @@ export class RedeemService {
},
});
if (!isTest) {
await this.settlementService.createStorePayout(
redeemRecord.id,
account.storeId,
@@ -212,6 +223,7 @@ export class RedeemService {
settlementRate,
tx,
);
}
return redeemRecord;
});
@@ -1322,9 +1322,12 @@ export class SettlementService {
cityId: primary.cityId,
payStatus: 'PAID',
paidAt: { gte: periodStart, lte: periodEnd },
isTest: false,
},
});
const orderCommission = orders.reduce((sum, o) => {
const orderCommission = primary.isTest
? 0
: orders.reduce((sum, o) => {
if (o.partnerAccountIdAtPay) {
if (o.partnerAccountIdAtPay !== primary.id) return sum;
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
@@ -1338,14 +1341,16 @@ export class SettlementService {
select: { id: true },
});
const storeIds = stores.map((s) => s.id);
const redeems = storeIds.length
? await this.prisma.redeemRecord.findMany({
const redeems =
primary.isTest || !storeIds.length
? []
: await this.prisma.redeemRecord.findMany({
where: {
storeId: { in: storeIds },
createdAt: { gte: periodStart, lte: periodEnd },
isTest: false,
},
})
: [];
});
const redeemCommission = redeems.reduce(
(sum, r) => sum + Number(r.amount) * redeemCommissionRate,
0,
@@ -1588,6 +1593,7 @@ export class SettlementService {
payStatus: 'PAID',
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
completedAt: { gte: start, lt: end },
isTest: false,
},
orderBy: { completedAt: 'asc' },
});
@@ -1921,6 +1927,7 @@ export class SettlementService {
quantity: true,
deliveryType: true,
payStatus: true,
isTest: true,
},
},
},
@@ -1929,6 +1936,7 @@ export class SettlementService {
const eligible = deliveries.filter(
(d) =>
!d.order.isTest &&
d.order.payStatus === 'PAID' &&
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'),
);
@@ -4,7 +4,12 @@ import {
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { STORE_PACKAGE_MAX_COUNT, type StorePackageItemDto } from '@dukang/shared-types';
import {
STORE_PACKAGE_IMAGE_MAX_COUNT,
STORE_PACKAGE_MAX_COUNT,
normalizeStorePackageImageUrls,
type StorePackageItemDto,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { StoreService } from './store.service';
@@ -44,9 +49,18 @@ export class StorePackageService {
const otherNotes = item.otherNotes != null && String(item.otherNotes).trim()
? String(item.otherNotes).trim()
: null;
const imageUrl = item.imageUrl != null && String(item.imageUrl).trim()
? String(item.imageUrl).trim()
: null;
const imageUrls = normalizeStorePackageImageUrls({
imageUrl: item.imageUrl as string | null | undefined,
imageUrls: item.imageUrls,
});
if (
Array.isArray(item.imageUrls) &&
item.imageUrls.filter((u) => String(u ?? '').trim()).length > STORE_PACKAGE_IMAGE_MAX_COUNT
) {
throw new BadRequestException(
`${index + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`,
);
}
const sortOrder = item.sortOrder != null ? Number(item.sortOrder) : index;
return {
name,
@@ -54,7 +68,8 @@ export class StorePackageService {
dishes,
usableTime,
otherNotes,
imageUrl,
imageUrl: imageUrls[0] ?? null,
imageUrls,
sortOrder: Number.isFinite(sortOrder) ? sortOrder : index,
};
}
@@ -67,8 +82,13 @@ export class StorePackageService {
usableTime: string | null;
otherNotes: string | null;
imageUrl: string | null;
imageUrls: Prisma.JsonValue | null;
sortOrder: number;
}) {
const imageUrls = normalizeStorePackageImageUrls({
imageUrl: row.imageUrl,
imageUrls: row.imageUrls,
});
return {
id: row.id.toString(),
name: row.name,
@@ -76,7 +96,8 @@ export class StorePackageService {
dishes: row.dishes,
usableTime: row.usableTime,
otherNotes: row.otherNotes,
imageUrl: row.imageUrl,
imageUrl: imageUrls[0] ?? null,
imageUrls,
sortOrder: row.sortOrder,
};
}
@@ -208,6 +229,19 @@ export class StorePackageService {
return serializeBigInt({ live });
}
private packageImageWrite(pkg: {
imageUrl?: string | null;
imageUrls?: string[] | null;
}) {
const imageUrls = normalizeStorePackageImageUrls(pkg);
return {
imageUrl: imageUrls[0] ?? null,
imageUrls: imageUrls.length
? (imageUrls as Prisma.InputJsonValue)
: Prisma.JsonNull,
};
}
private async replaceLivePackages(storeId: bigint, packages: StorePackageItemDto[]) {
await this.prisma.$transaction([
this.prisma.storePackage.deleteMany({ where: { storeId } }),
@@ -220,7 +254,7 @@ export class StorePackageService {
dishes: pkg.dishes,
usableTime: pkg.usableTime ?? null,
otherNotes: pkg.otherNotes ?? null,
imageUrl: pkg.imageUrl ?? null,
...this.packageImageWrite(pkg),
sortOrder: pkg.sortOrder ?? index,
},
}),
@@ -312,7 +346,7 @@ export class StorePackageService {
});
return serializeBigInt({ id: updated.id.toString(), status: updated.status });
}
const packages = req.packagesJson as unknown as StorePackageItemDto[];
const packages = this.normalizePackages(req.packagesJson);
await this.prisma.$transaction(async (tx) => {
await tx.storePackage.deleteMany({ where: { storeId: req.storeId } });
for (let i = 0; i < packages.length; i++) {
@@ -325,6 +359,7 @@ export class StorePackageService {
dishes: pkg.dishes,
usableTime: pkg.usableTime ?? null,
otherNotes: pkg.otherNotes ?? null,
...this.packageImageWrite(pkg),
sortOrder: pkg.sortOrder ?? i,
},
});
@@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import { validateBusinessHours } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@@ -16,6 +17,10 @@ import { PartnerCityService } from '../city-scope/partner-city.service';
import { AuthService } from '../iam/auth.service';
import { StoreCategoryService } from './store-category.service';
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
import {
TestWhitelistService,
normalizeTestPhone,
} from '../../common/test-whitelist/test-whitelist.service';
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
@@ -43,10 +48,6 @@ export type StoreViewer = {
bypassWhitelist?: boolean;
};
function normalizePhone(phone: string | null | undefined): string {
return (phone || '').replace(/\D/g, '').trim();
}
function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null {
if (value == null || value === '') return null;
const n = typeof value === 'number' ? value : Number(value);
@@ -67,8 +68,16 @@ export class StoreService {
private readonly authService: AuthService,
private readonly storeCategoryService: StoreCategoryService,
private readonly tencentLbs: TencentLbsProvider,
private readonly testWhitelist: TestWhitelistService,
) {}
private async whitelistPhoneSet(): Promise<Set<string>> {
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
select: { phone: true },
});
return new Set(rows.map((r) => normalizeTestPhone(r.phone)).filter(Boolean));
}
private storeAddressText(store: {
province?: string | null;
cityName?: string | null;
@@ -116,17 +125,16 @@ export class StoreService {
}
isVisibleToViewer(
store: {
visibilityWhitelistEnabled: boolean;
visibilityPhones: Array<{ phone: string }>;
},
store: { visibilityWhitelistEnabled: boolean },
viewer?: StoreViewer,
whitelistPhones?: Set<string>,
): boolean {
if (viewer?.bypassWhitelist) return true;
if (!store.visibilityWhitelistEnabled) return true;
const phone = normalizePhone(viewer?.phone);
const phone = normalizeTestPhone(viewer?.phone);
if (!phone) return false;
return store.visibilityPhones.some((row) => normalizePhone(row.phone) === phone);
if (whitelistPhones) return whitelistPhones.has(phone);
return false;
}
async listOpenStores(
@@ -145,12 +153,14 @@ export class StoreService {
include: {
category: true,
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
orderBy: { createdAt: 'desc' },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer));
const whitelistPhones = stores.some((s) => s.visibilityWhitelistEnabled)
? await this.whitelistPhoneSet()
: new Set<string>();
const visible = stores.filter((s) => this.isVisibleToViewer(s, viewer, whitelistPhones));
const hasUser =
userLat != null &&
@@ -162,12 +172,13 @@ export class StoreService {
distanceMeters: number | null;
latitude?: unknown;
longitude?: unknown;
sortOrder?: number;
};
const items: StoreListItem[] = [];
for (const store of visible) {
const coords = await this.ensureStoreCoordinates(store);
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
const mapped = mapStoreCompat({
...rest,
latitude: coords?.latitude ?? store.latitude,
@@ -180,13 +191,16 @@ export class StoreService {
items.push({ ...mapped, distanceMeters });
}
if (hasUser) {
items.sort((a, b) => {
const so = (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
if (so !== 0) return so;
if (hasUser) {
const da = a.distanceMeters ?? Number.POSITIVE_INFINITY;
const db = b.distanceMeters ?? Number.POSITIVE_INFINITY;
return da - db;
});
}
return 0;
});
return serializeBigInt(items);
}
@@ -197,10 +211,12 @@ export class StoreService {
include: {
category: true,
coverResource: true,
visibilityPhones: { select: { phone: true } },
},
});
if (!store || !this.isVisibleToViewer(store, viewer)) {
const whitelistPhones = store?.visibilityWhitelistEnabled
? await this.whitelistPhoneSet()
: new Set<string>();
if (!store || !this.isVisibleToViewer(store, viewer, whitelistPhones)) {
throw new NotFoundException('门店不存在');
}
const coords = await this.ensureStoreCoordinates(store);
@@ -212,22 +228,30 @@ export class StoreService {
where: { storeId: id },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
const { visibilityPhones: _phones, visibilityWhitelistEnabled: _wl, ...rest } = store;
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
return serializeBigInt(
mapStoreCompat({
...rest,
latitude: coords?.latitude ?? store.latitude,
longitude: coords?.longitude ?? store.longitude,
media,
packages: packageRows.map((p) => ({
packages: packageRows.map((p) => {
const imageUrls = normalizeStorePackageImageUrls({
imageUrl: p.imageUrl,
imageUrls: p.imageUrls,
});
return {
name: p.name,
price: p.price.toFixed(2),
dishes: p.dishes,
usableTime: p.usableTime,
otherNotes: p.otherNotes,
imageUrl: p.imageUrl,
imageUrl: imageUrls[0] ?? null,
/** C 端始终返回数组,便于多图轮播 */
imageUrls,
sortOrder: p.sortOrder,
})),
};
}),
}),
);
}
@@ -249,7 +273,7 @@ export class StoreService {
const stores = await this.prisma.store.findMany({
where,
include: { category: true, coverResource: true },
orderBy: { createdAt: 'desc' },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
return serializeBigInt(stores.map(mapStoreCompat));
}
@@ -1281,6 +1305,7 @@ export class StoreService {
if (!url || seen.has(url)) continue;
seen.add(url);
urls.push(url);
if (urls.length >= 20) break;
}
return urls;
}
@@ -218,6 +218,11 @@ export class TradeService {
const promoCodeId =
attribution?.promoCode?.status === 'ACTIVE' ? attribution.promoCodeId : undefined;
const buyer = await this.prisma.user.findUnique({
where: { id: userId },
select: { isTest: true },
});
const order = await this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
@@ -258,6 +263,7 @@ export class TradeService {
benefitAmount: preview.benefitAmount,
payExpireAt,
promoCodeId,
isTest: !!buyer?.isTest,
},
include: { product: true, imageResource: true },
});
+2 -1
View File
@@ -19,7 +19,8 @@
| 3.4.11 | 08-04 | 开发计划 + 企微机器人/消息推送 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) |
| 3.4.12 | 08-04 | 退款回滚/财务/批量任务·工单/套餐 imageUrl | [`v3.4.12`](./杜康好客-v3.4.12-工单迭代开发文档.md) |
| 3.4.13 | 08-05 | metrics/核销详情/版本联动 PUBLISHED/mini 体验/H5 OAuth | [`v3.4.13`](./杜康好客-v3.4.13-体验优化开发文档.md) |
| 3.4.14 | 08-06 | mini-user 门头/套餐详情;**微信小程序配置可配 Logo·客服·H5·Mock** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) |
| 3.4.14 | 08-06 | mini-user 门头/套餐详情;小程序配置;**统一测试白名单(不计账+限测可见+Mock旁路)** | [`v3.4.14`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) |
| 3.4.15 | 08-07 | mini-user 门店列表卡片;HQ 门店照片替换/删除;套餐多图上限 20 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
---
+5 -1
View File
@@ -55,12 +55,16 @@
| 3.4.11 | [`开发计划`](./杜康好客-开发计划功能开发文档-v3.4.11.md) | ✅ |
| 3.4.12 | [`工单迭代`](./杜康好客-v3.4.12-工单迭代开发文档.md) | ✅ |
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | 🔶 开发中 |
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | 🔶 开发中 |
| 3.4.15 | [`mini-user 门店列表 + HQ 照片/套餐多图`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | 🔶 开发中 |
## 5. 变更记录
| 日期 | 说明 |
|------|------|
| 2026-08-07 | v3.4.14 增补统一测试白名单(不计账 / 限测可见 / Mock 旁路) |
| 2026-08-07 | v3.4.15 增补:HQ 门店照片替换/删除、套餐多图上限 20 |
| 2026-08-06 | v3.4.15 mini-user 门店列表卡片优化(开发中) |
| 2026-08-06 | v3.4.14 mini-user 门头/套餐详情(开发中) |
| 2026-08-05 | v3.4.13 |
| 2026-08-04 | v3.4.11 / v3.4.12 |
@@ -1,4 +1,4 @@
# 杜康好客 · v3.4.14 mini-user 门店体验 + 小程序可配置
# 杜康好客 · v3.4.14 mini-user 门店体验 + 小程序可配置 + 测试白名单
> **2026-08-06** · **开发中** · PRD §0.6 · mini-user `3.4.14` · **未发版**
@@ -10,6 +10,7 @@
| 门店详情·套餐 | 仅完整标题纵向列表;点击进入详情 |
| 套餐详情页 | 实底导航让出胶囊区;内容区顶/左右留白;有图在门店名下;无图直接菜品 |
| **系统设置·微信小程序** | Logo / 资质图 / 客服电话 / C 端 H5 / Mock 验证码 **可配置**,经 `client-config` 下发 |
| **测试白名单** | 全局手机号名单;账号/门店/订单/核销打标;不计四条结算;合并商品/门店可见性手机号;HQ 独立管理模块 |
## 页面路由
@@ -37,6 +38,36 @@ HQ → 系统设置 → **微信小程序配置**。启动时空缺键用 shared
**下发**`GET /common/client-config` 增加 `userH5Url``brandLogoUrl``brandLogoWideUrl``brandLogoMarkUrl``qualificationDisclosureUrl``customerServicePhone`
`MOCK_SMS_FIXED_CODE` 仅服务端 Mock 短信读取,不下发客户端。
## 测试白名单(统一)
### 规则
| 规则 | 说明 |
|------|------|
| 源真相 | HQ「白名单管理」维护 `common_test_whitelist_phone`;名单内手机号 = 测试账号 |
| 可见性 | 商品/门店 `visibilityWhitelistEnabled` 开启后,C 端仅当观众手机号 ∈ 全局名单可见/可购(不再用分实体 `*_visibility_phone` |
| 打标 | `User` / `StoreAccount` / `PartnerAccount` / `Store` / `Order` / `RedeemRecord``isTest` |
| 不计账 | 测试核销不产生 `StorePayout`;酒厂/物流/合伙人账单排除 `isTest` 流水 |
| 验证旁路 | 页顶 Checkbox ↔ `MOCK_SMS` / `MOCK_WECHAT` / `MOCK_PAY`(勾选 = 不做真实验证) |
### API
| 路径 | 说明 |
|------|------|
| `GET/POST /admin/test-whitelist/phones` | 名单列表 / 新增 |
| `PATCH/DELETE /admin/test-whitelist/phones/:id` | 改备注 / 删除 |
| `GET /admin/test-whitelist/accounts` | 测试账号记录(type=user\|store_account\|partner\|store\|order |
| `GET /admin/test-whitelist/phones/:id/linked` | 单号关联实体 |
| `POST /admin/test-whitelist/migrate-visibility` | 旧可见性手机号导入 |
业务列表查询参数:`excludeTest=true` 排除测试数据。
### HQ
- 路由 `/test-whitelist`:验证旁路 + 手机号名单 + 测试账号记录
- 用户/订单/门店/门店账号/合伙人/核销:「过滤测试账号」复选框;列表「测试」Tag
- 商品/门店:保留「仅白名单可见」开关,去掉分实体手机号编辑
## ACC
- [ ] 门头区高度固定(4:3);图片 aspectFit 完整缩放;标题 section 位置不随图高变化
@@ -45,6 +76,11 @@ HQ → 系统设置 → **微信小程序配置**。启动时空缺键用 shared
- [ ] 无套餐时不展示区块;index 非法时友好提示
- [ ] HQ 微信小程序配置可改 Logo/电话/落地页/Mock 码;保存后 client-config 立即生效(无需重启)
- [ ] mini-user 登录/我的/客服/分享图读取配置;未配置时回退代码默认常量
- [ ] HQ「白名单管理」可增删手机号;可查看测试账号记录
- [ ] 页顶三 Checkbox 控制短信/微信/支付跳过真实验证,与 `MOCK_*` 同源立即生效
- [ ] 业务列表「过滤测试账号」勾选后不含测试数据
- [ ] 旧可见性手机号已导入;限测商品/门店仅全局名单可见
- [ ] 测试流水不进四条账单与门店打款
## HQ 开发计划

Some files were not shown because too many files have changed in this diff Show More