34 Commits

Author SHA1 Message Date
jacy 3913bf3bf3 merge(dev_jacy): admin contract/package/winery fixes 2026-08-10 20:31:05 +08:00
jacy 478291e2b3 fix(admin): contract preview, package audit detail, empty packages, winery status
Allow HQ to save stores with zero packages, preview contract images/PDFs, inspect package audit images/content, and show gray 无需打款 when winery payable is 0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 20:30:55 +08:00
jacy 92c3175acf merge(dev_jacy): v3.4.16 partner UX and CS gate 2026-08-09 16:00:57 +08:00
jacy 46361ec713 feat(v3.4.16): partner UX, package pricing, CS gate, HQ list polish
Stop auto ST from validation_error; show package prices with right-aligned layout; partner store list status/filter and onboard CS QR gate; HQ store table truncation/fixed actions; expose CS config in wechat_mini settings; bind local servers for LAN.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 16:00:45 +08:00
jacy 4cec5b1fe5 merge(dev_jacy): store contactPhone separate from login 2026-08-09 07:50:50 +08:00
jacy 07630c9046 feat(store): separate login phone from public contactPhone
Store.phone stays login/boss; contactPhone is C-end dial. Public store APIs keep phone compat via publicDial mapping.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 07:50:40 +08:00
jacy 99c74f20d2 merge(dev_jacy): save packages with store 保存修改 2026-08-07 21:36:38 +08:00
jacy a598d1cd30 fix(admin): save packages when clicking store 保存修改
HQ users were uploading package images then clicking drawer 保存修改, which only persisted store media and never PUT packages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 21:36:28 +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 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 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 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 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 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 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 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 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 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 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 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 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
116 changed files with 5265 additions and 1487 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>
@@ -1,21 +1,47 @@
import { useEffect, useState } from 'react';
import { forwardRef, useEffect, useImperativeHandle, useRef, 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;
export type AdminStorePackagesHandle = {
/** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
};
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 }) {
const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId: string }>(
function AdminStorePackagesSection({ storeId }, ref) {
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const itemsRef = useRef(items);
const loadingRef = useRef(loading);
useEffect(() => {
itemsRef.current = items;
}, [items]);
useEffect(() => {
loadingRef.current = loading;
}, [loading]);
useEffect(() => {
setLoading(true);
@@ -23,8 +49,17 @@ 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 }))
: [emptyRow()],
? data.live.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
})
: [],
);
})
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
@@ -68,56 +103,95 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
}
run();
}
function toggleCollapse(index: number) {
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
}
async function save() {
const filled = items
.map((item, index) => ({
async function save(opts?: { quiet?: boolean }) {
const currentItems = itemsRef.current;
const filled = currentItems
.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);
};
})
// 允许整店无套餐:忽略空白占位行(默认 price=0 不算已填)
.filter((item) => {
const hasText = !!(item.name || item.dishes || item.usableTime || item.otherNotes);
const hasImages = item.imageUrls.length > 0;
const hasNonZeroPrice = item.price !== '' && Number(item.price) !== 0;
return hasText || hasImages || hasNonZeroPrice;
});
for (let i = 0; i < filled.length; i++) {
const item = filled[i];
if (!item.name) {
message.warning(`${i + 1} 条套餐名称不能为空`);
return;
throw new Error('套餐校验失败');
}
if (!item.dishes) {
message.warning(`${i + 1} 条套餐菜品不能为空`);
return;
throw new Error('套餐校验失败');
}
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) {
message.warning(`${i + 1} 条套餐价格须为非负数字`);
return;
throw new Error('套餐校验失败');
}
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
message.warning(`${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`);
throw new Error('套餐校验失败');
}
}
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('套餐已保存并生效');
if (!opts?.quiet) 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,
};
})
: [],
);
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
throw e;
} finally {
setSaving(false);
}
}
useImperativeHandle(ref, () => ({
saveIfLoaded: async (opts) => {
if (loadingRef.current) return { skipped: true };
await save(opts);
return { skipped: false };
},
}));
if (loading) {
return <Typography.Text type="secondary"></Typography.Text>;
}
@@ -129,14 +203,15 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
<Button onClick={addRow} style={{ marginBottom: 16 }}>
</Button>
<Button type="primary" loading={saving} onClick={() => void save()}>
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
</Button>
</Form>
);
}
return ( <Form layout="vertical" requiredMark={false}>
return (
<Form layout="vertical" requiredMark={false}>
<Alert
type="info"
showIcon
@@ -173,7 +248,8 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
<Button type="link" danger onClick={() => removeAt(index)}>
</Button>
) : null} </Space>
) : null}
</Space>
{!isCollapsed ? (
<>
@@ -215,15 +291,19 @@ 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>
<Form.Item label="其他说明" style={{ marginBottom: 0 }}> <Input
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
<Input
placeholder="不可叠加"
value={item.otherNotes || ''}
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
@@ -242,12 +322,15 @@ 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()}>
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
</Button>
</Form>
);
}
},
);
export default AdminStorePackagesSection;
@@ -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>
);
}
+63 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Button, Image, Input, Space, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { Button, Image, Input, Modal, Space, Upload, message } from 'antd';
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
@@ -15,6 +15,14 @@ type OssUploadProps = {
placeholder?: string;
};
function isImageUrl(url: string) {
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url);
}
function isPdfUrl(url: string) {
return /\.pdf(\?|#|$)/i.test(url);
}
export default function OssUpload({
value,
onChange,
@@ -25,6 +33,7 @@ export default function OssUpload({
placeholder = '上传后自动填入,或手动粘贴 URL',
}: OssUploadProps) {
const [uploading, setUploading] = useState(false);
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
const resolvedAccept =
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
@@ -47,6 +56,57 @@ export default function OssUpload({
}
};
const filePreview =
value && mediaType === 'FILE' ? (
isImageUrl(value) ? (
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
) : isPdfUrl(value) ? (
<Space direction="vertical" size={8}>
<div
style={{
width: 120,
height: 120,
borderRadius: 4,
border: '1px solid #f0f0f0',
background: '#fafafa',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
color: '#cf1322',
}}
>
<FilePdfOutlined style={{ fontSize: 36 }} />
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>PDF </span>
</div>
<Space wrap>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setPdfPreviewOpen(true)}>
</Button>
<Button type="link" size="small" href={value} target="_blank" rel="noreferrer">
</Button>
</Space>
<Modal
title="签约合同预览"
open={pdfPreviewOpen}
onCancel={() => setPdfPreviewOpen(false)}
footer={null}
width="90vw"
styles={{ body: { height: '75vh', padding: 0 } }}
destroyOnClose
>
<iframe title="合同 PDF 预览" src={value} style={{ width: '100%', height: '100%', border: 0 }} />
</Modal>
</Space>
) : (
<Button type="link" href={value} target="_blank" rel="noreferrer" style={{ paddingLeft: 0 }}>
</Button>
)
) : null;
return (
<Space direction="vertical" style={{ width: '100%' }} size="small">
{value && mediaType === 'IMAGE' && (
@@ -55,6 +115,7 @@ export default function OssUpload({
{value && mediaType === 'VIDEO' && (
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
)}
{filePreview}
<Space wrap>
<Upload
accept={resolvedAccept}
@@ -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
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import {
Button,
Drawer,
Image,
Input,
Modal,
Space,
@@ -17,7 +18,10 @@ import type {
StorePackageItemDto,
StorePackageViewDto,
} from '@dukang/shared-types';
import { STORE_PACKAGE_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
import {
STORE_PACKAGE_CHANGE_STATUS_LABELS,
normalizeStorePackageImageUrls,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
@@ -26,6 +30,10 @@ function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: numbe
return name ? `name:${name}` : `idx:${index}`;
}
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
return normalizeStorePackageImageUrls(pkg).join('|');
}
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
@@ -49,7 +57,8 @@ function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto
l.price !== p.price ||
l.dishes !== p.dishes ||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
(l.otherNotes ?? '') !== (p.otherNotes ?? '');
(l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
imageSignature(l) !== imageSignature(p);
rows.push({ key, change: changed ? 'changed' : 'unchanged', live: l, proposed: p });
}
}
@@ -63,6 +72,108 @@ const CHANGE_LABELS = {
unchanged: { text: '未变', color: 'default' },
} as const;
function PackageDetailCard({
title,
pkg,
}: {
title?: string;
pkg: StorePackageItemDto | StorePackageViewDto;
}) {
const images = normalizeStorePackageImageUrls(pkg);
return (
<div
style={{
marginBottom: 12,
padding: 12,
border: '1px solid #f0f0f0',
borderRadius: 8,
background: '#fafafa',
}}
>
{title ? (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{title}
</Typography.Text>
) : null}
<div style={{ marginBottom: 8 }}>
<strong>{pkg.name}</strong>
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
</div>
<Typography.Paragraph style={{ marginBottom: 8, whiteSpace: 'pre-wrap' }}>
{pkg.dishes || '—'}
</Typography.Paragraph>
{pkg.usableTime ? (
<div style={{ marginBottom: 4 }}>
<Typography.Text type="secondary">{pkg.usableTime}</Typography.Text>
</div>
) : null}
{pkg.otherNotes ? (
<div style={{ marginBottom: 8 }}>
<Typography.Text type="secondary">{pkg.otherNotes}</Typography.Text>
</div>
) : null}
{images.length ? (
<Image.PreviewGroup>
<Space wrap size={8}>
{images.map((url) => (
<Image
key={url}
src={url}
width={72}
height={72}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
))}
</Space>
</Image.PreviewGroup>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</div>
);
}
function PackageSummaryCell({ pkg }: { pkg?: StorePackageItemDto | StorePackageViewDto }) {
if (!pkg) return <></>;
const images = normalizeStorePackageImageUrls(pkg);
return (
<div>
<div>
<strong>{pkg.name}</strong> · ¥{pkg.price}
</div>
<Typography.Text type="secondary">{pkg.dishes}</Typography.Text>
{pkg.usableTime ? (
<div>
<Typography.Text type="secondary">{pkg.usableTime}</Typography.Text>
</div>
) : null}
{pkg.otherNotes ? (
<div>
<Typography.Text type="secondary">{pkg.otherNotes}</Typography.Text>
</div>
) : null}
{images.length ? (
<Image.PreviewGroup>
<Space wrap size={4} style={{ marginTop: 8 }}>
{images.slice(0, 4).map((url) => (
<Image
key={url}
src={url}
width={48}
height={48}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
))}
{images.length > 4 ? (
<Typography.Text type="secondary">+{images.length - 4}</Typography.Text>
) : null}
</Space>
</Image.PreviewGroup>
) : null}
</div>
);
}
export default function StorePackageAuditsPage() {
const [loading, setLoading] = useState(false);
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
@@ -75,6 +186,11 @@ export default function StorePackageAuditsPage() {
const [detailOpen, setDetailOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
const [pkgDetailOpen, setPkgDetailOpen] = useState(false);
const [pkgDetailTitle, setPkgDetailTitle] = useState('');
const [pkgDetailList, setPkgDetailList] = useState<Array<StorePackageItemDto | StorePackageViewDto>>(
[],
);
async function reload(nextPage = page, nextStatus = status) {
setLoading(true);
@@ -116,6 +232,15 @@ export default function StorePackageAuditsPage() {
}
}
function openPackageDetails(
title: string,
list: Array<StorePackageItemDto | StorePackageViewDto>,
) {
setPkgDetailTitle(title);
setPkgDetailList(list);
setPkgDetailOpen(true);
}
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
try {
await request(`/admin/store-package-audits/${id}/audit`, {
@@ -146,39 +271,11 @@ export default function StorePackageAuditsPage() {
},
{
title: '当前线上',
render: (_, row) =>
row.live ? (
<div>
<div><strong>{row.live.name}</strong> · ¥{row.live.price}</div>
<Typography.Text type="secondary">{row.live.dishes}</Typography.Text>
{row.live.usableTime ? (
<div><Typography.Text type="secondary">{row.live.usableTime}</Typography.Text></div>
) : null}
{row.live.otherNotes ? (
<div><Typography.Text type="secondary">{row.live.otherNotes}</Typography.Text></div>
) : null}
</div>
) : (
'—'
),
render: (_, row) => <PackageSummaryCell pkg={row.live} />,
},
{
title: '申请变更',
render: (_, row) =>
row.proposed ? (
<div>
<div><strong>{row.proposed.name}</strong> · ¥{row.proposed.price}</div>
<Typography.Text type="secondary">{row.proposed.dishes}</Typography.Text>
{row.proposed.usableTime ? (
<div><Typography.Text type="secondary">{row.proposed.usableTime}</Typography.Text></div>
) : null}
{row.proposed.otherNotes ? (
<div><Typography.Text type="secondary">{row.proposed.otherNotes}</Typography.Text></div>
) : null}
</div>
) : (
'—'
),
render: (_, row) => <PackageSummaryCell pkg={row.proposed} />,
},
];
@@ -257,7 +354,7 @@ export default function StorePackageAuditsPage() {
<Drawer
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
width={720}
width={880}
open={detailOpen}
onClose={() => setDetailOpen(false)}
extra={
@@ -282,7 +379,7 @@ export default function StorePackageAuditsPage() {
<Typography.Text type="secondary"></Typography.Text>
) : detail ? (
<>
<Space style={{ marginBottom: 16 }}>
<Space style={{ marginBottom: 16 }} wrap>
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]}</Tag>
<Typography.Text type="secondary">
{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
@@ -291,9 +388,27 @@ export default function StorePackageAuditsPage() {
{detail.rejectReason ? (
<Typography.Paragraph type="danger">{detail.rejectReason}</Typography.Paragraph>
) : null}
<Typography.Paragraph type="secondary">
<Space style={{ marginBottom: 12 }} wrap>
<Typography.Text type="secondary">
线 {detail.livePackages?.length ?? 0} {detail.packages?.length ?? 0}
</Typography.Paragraph>
</Typography.Text>
<Button
size="small"
onClick={() =>
openPackageDetails('申请套餐详情', detail.packages ?? [])
}
>
</Button>
<Button
size="small"
onClick={() =>
openPackageDetails('线上套餐详情', detail.livePackages ?? [])
}
>
线
</Button>
</Space>
<Table
size="small"
rowKey="key"
@@ -305,6 +420,23 @@ export default function StorePackageAuditsPage() {
) : null}
</Drawer>
<Modal
title={pkgDetailTitle || '套餐详情'}
open={pkgDetailOpen}
onCancel={() => setPkgDetailOpen(false)}
footer={null}
width={720}
destroyOnClose
>
{pkgDetailList.length ? (
pkgDetailList.map((pkg, index) => (
<PackageDetailCard key={`${pkg.name}-${index}`} title={`套餐 ${index + 1}`} pkg={pkg} />
))
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Modal>
<Modal
title="驳回套餐变更"
open={rejectOpen}
+197 -285
View File
@@ -3,6 +3,7 @@ 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,8 +40,11 @@ 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';
import AdminStorePackagesSection, {
type AdminStorePackagesHandle,
} from '../components/AdminStorePackagesSection';
const CREATE_STEPS = [
{ title: '基本信息' },
@@ -81,18 +84,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 +109,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 +149,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 +177,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 +194,7 @@ type StoreRow = {
closeTime2?: string | null;
avgPrice?: number | null;
settlementRate?: number;
sortOrder?: number;
category?: { id: string; name: string; parentId?: string | null } | null;
};
@@ -424,8 +228,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 +238,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],
@@ -448,6 +253,7 @@ export default function StoresPage() {
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const packagesRef = useRef<AdminStorePackagesHandle>(null);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [auditing, setAuditing] = useState(false);
@@ -562,12 +368,18 @@ export default function StoresPage() {
account?.phone ||
(typeof d.phone === 'string' ? d.phone : undefined);
const storePhone = typeof d.phone === 'string' ? d.phone : undefined;
const contactPhone =
(typeof d.contactPhone === 'string' && d.contactPhone.trim()) ||
storePhone ||
loginPhone ||
'';
const phoneMismatchNow = !!(loginPhone && storePhone && loginPhone !== storePhone);
setPhoneMismatch(phoneMismatchNow ? String(loginPhone) : null);
editForm.setFieldsValue({
name: d.name,
// 以门店手机号为准保存;若与账号登录号不一致,保存时会强制同步到登录账号
phone: storePhone || loginPhone,
// 登录手机号(老板);与 StoreAccount 同步
phone: loginPhone || storePhone,
contactPhone,
intro: d.intro,
benefitUsageRule:
d.benefitUsageRule != null &&
@@ -576,6 +388,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 +418,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 +437,12 @@ export default function StoresPage() {
const payload = {
name: v.name,
phone: v.phone,
coverUrl: v.coverUrl,
contactPhone: String(v.contactPhone || '').trim() || v.phone,
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 +465,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) }
: {}),
@@ -648,7 +475,10 @@ export default function StoresPage() {
method: 'PUT',
body: JSON.stringify(payload),
});
message.success('门店信息已保存');
const packagesResult = await packagesRef.current?.saveIfLoaded({ quiet: true });
message.success(
packagesResult?.skipped === false ? '门店信息与套餐已保存' : '门店信息已保存',
);
setDetail(updated);
setPhoneMismatch(null);
void reload();
@@ -722,8 +552,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,
@@ -784,6 +615,7 @@ export default function StoresPage() {
city: values.city,
name: values.name.trim(),
phone: values.phone.trim(),
contactPhone: String(values.contactPhone || values.phone || '').trim(),
district: values.district.trim(),
address: values.address.trim(),
...(values.latitude != null &&
@@ -812,10 +644,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,14 +669,37 @@ 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: 180,
ellipsis: { showTitle: false },
render: (v: string, row) => {
const name = v || '—';
return (
<Space size={4} style={{ maxWidth: '100%' }} wrap={false}>
<Typography.Text ellipsis={{ tooltip: name }} style={{ maxWidth: row.isTest ? 110 : 160 }}>
{name}
</Typography.Text>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
);
},
},
{
title: '分类',
width: 100,
ellipsis: true,
render: (_, row) => row.category?.name || '—',
},
{ title: '城市', dataIndex: 'cityName', width: 80 },
{ title: '电话', dataIndex: 'phone', width: 120 },
{ title: '城市', dataIndex: 'cityName', width: 80, ellipsis: true },
{ title: '登录号', dataIndex: 'phone', width: 120 },
{
title: '联系电话',
dataIndex: 'contactPhone',
width: 120,
render: (v: string | null | undefined, row) => v || row.phone,
},
{
title: '营业状态', dataIndex: 'status', width: 90,
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
@@ -857,10 +710,10 @@ export default function StoresPage() {
const status = s || 'APPROVED';
const color = status === 'PENDING' ? 'orange' : status === 'REJECTED' ? 'red' : 'green';
return (
<Space direction="vertical" size={0}>
<Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
{status === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
<Typography.Text type="secondary" style={{ fontSize: 12, maxWidth: 88 }} ellipsis={{ tooltip: row.rejectReason }}>
{row.rejectReason}
</Typography.Text>
) : null}
@@ -872,21 +725,32 @@ export default function StoresPage() {
title: '开城合伙人',
dataIndex: 'partner',
width: 140,
render: (partner: StoreRow['partner']) =>
partner ? partnerOptionLabel({ id: partner.id ?? '', ...partner }) : '—',
ellipsis: { showTitle: false },
render: (partner: StoreRow['partner']) => {
if (!partner) return '—';
const label = partnerOptionLabel({ id: partner.id ?? '', ...partner });
return (
<Typography.Text ellipsis={{ tooltip: label }} style={{ maxWidth: 124 }}>
{label}
</Typography.Text>
);
},
},
{
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: ['account', 'name'], width: 90, render: (v) => v || '—' },
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, ellipsis: true, render: (v) => v || '—' },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 140,
title: '操作',
width: 140,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}></Button>
@@ -939,6 +803,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
@@ -955,8 +822,24 @@ export default function StoresPage() {
</Button>
</Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1720 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Space wrap>
@@ -1053,18 +936,26 @@ export default function StoresPage() {
type="warning"
showIcon
style={{ marginBottom: 16 }}
message={`登录账号手机号仍${phoneMismatch},与门店手机号不一致。请点击右上角「保存修改」同步,否则门店端无法用新号登录`}
message={`主账号登录号${phoneMismatch},与门店登录字段不一致。保存「登录手机号」将同步到门店端登录账号`}
/>
) : null}
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item
name="phone"
label="登录手机号"
label="登录手机号(老板)"
rules={[{ required: true }]}
extra="门店端短信登录使用此号码;修改后需用新号重新登录"
extra="门店端主账号短信登录;修改后需用新号重新登录"
>
<Input />
</Form.Item>
<Form.Item
name="contactPhone"
label="联系电话(店长/对外)"
rules={[{ required: true, message: '请填写对外联系电话' }]}
extra="用户端门店详情展示与拨号使用此号码,可与登录号不同"
>
<Input placeholder="11位手机号" />
</Form.Item>
<Form.Item
name="categoryParentId"
label="门店分类(大类)"
@@ -1159,6 +1050,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 +1074,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,12 +1126,14 @@ export default function StoresPage() {
{
key: 'media',
label: '审核材料',
children: <StoreAuditMediaSection detail={detail} />,
forceRender: true,
children: <StoreAuditMediaEditor />,
},
{
key: 'packages',
label: '套餐',
children: <AdminStorePackagesSection storeId={String(detail.id)} />,
forceRender: true,
children: <AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />,
},
]}
/>
@@ -1323,8 +1231,18 @@ export default function StoresPage() {
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
<Input placeholder="请输入门店名称" />
</Form.Item>
<Form.Item name="phone" label="门店手机号(登录账号" rules={[{ required: true, message: '请填写门店手机号' }]}>
<Input placeholder="11位手机号" />
<Form.Item name="phone" label="登录手机号(老板" rules={[{ required: true, message: '请填写登录手机号' }]}>
<Input placeholder="门店端主账号登录" />
</Form.Item>
<Form.Item
name="contactPhone"
label="联系电话(店长/对外)"
extra="用户端拨号展示;留空则与登录号相同"
>
<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="请输入详细门牌号" />
@@ -1410,24 +1328,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>
+39 -11
View File
@@ -51,13 +51,35 @@ type BillItem = {
const STATUS_LABELS: Record<string, string> = {
UNPAID: '未打款',
PAID: '已打款',
NO_PAYMENT_NEEDED: '无需打款',
};
const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red',
PAID: 'green',
NO_PAYMENT_NEEDED: 'default',
};
function isZeroPayable(amount: number | string | null | undefined) {
return Number(amount ?? 0) === 0;
}
/** 应付为 0 时展示「无需打款」(灰),否则按 DB 打款状态 */
function displayWineryStatus(status: string, wineryAmount: number | string) {
if (isZeroPayable(wineryAmount)) {
return { key: 'NO_PAYMENT_NEEDED', label: STATUS_LABELS.NO_PAYMENT_NEEDED, color: STATUS_COLORS.NO_PAYMENT_NEEDED };
}
return {
key: status,
label: STATUS_LABELS[status] || status,
color: STATUS_COLORS[status] || 'default',
};
}
function canConfirmWineryPay(row: { status: string; wineryAmount: number | string }) {
return row.status === 'UNPAID' && !isZeroPayable(row.wineryAmount);
}
const DELIVERY_LABELS: Record<string, string> = {
LOCAL: '同城',
CROSS_CITY: '跨城',
@@ -212,7 +234,7 @@ export default function WineryBillsPage() {
render: (v) => `${Math.round(Number(v) * 100)}%`,
},
{
title: '酒厂应付',
title: '应付',
dataIndex: 'wineryAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
@@ -220,8 +242,11 @@ export default function WineryBillsPage() {
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
width: 100,
render: (s, row) => {
const d = displayWineryStatus(s, row.wineryAmount);
return <Tag color={d.color}>{d.label}</Tag>;
},
},
{
title: '操作',
@@ -232,7 +257,7 @@ export default function WineryBillsPage() {
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{row.status === 'UNPAID' && (
{canConfirmWineryPay(row) && (
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
</Button>
@@ -258,7 +283,8 @@ export default function WineryBillsPage() {
</Typography.Title>
<Typography.Text type="secondary">
T+3 8:00 3 / × {ratePct}%绿
T+3 8:00 3 / × {ratePct}%绿 0
</Typography.Text>
</Space>
{canEditWineryBank ? (
@@ -273,7 +299,7 @@ export default function WineryBillsPage() {
<Space size="large" wrap>
<Statistic title="账单数" value={summary.count} />
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
</Space>
</Card>
)}
@@ -296,7 +322,7 @@ export default function WineryBillsPage() {
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
style={{ width: 130 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
@@ -347,7 +373,7 @@ export default function WineryBillsPage() {
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
}}
scroll={{ x: 1100 }}
pagination={{
@@ -368,8 +394,10 @@ export default function WineryBillsPage() {
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
<Descriptions.Item label="酒厂应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
<Descriptions.Item label="应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">
{displayWineryStatus(detail.status, detail.wineryAmount).label}
</Descriptions.Item>
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
@@ -395,7 +423,7 @@ export default function WineryBillsPage() {
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '酒厂应付',
title: '应付',
dataIndex: 'wineryAmount',
width: 90,
render: (v) => `¥${Number(v).toFixed(2)}`,
+4 -1
View File
@@ -1,10 +1,13 @@
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: {
host: true,
port: 5175,
proxy: { '/api': 'http://localhost:3000' },
proxy: { '/api': apiTarget },
},
});
@@ -0,0 +1,200 @@
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 urlsRef = useRef(urls);
const onChangeRef = useRef(onChange);
const remaining = Math.max(0, maxCount - urls.length);
const inWechat = isWechatEnv();
useEffect(() => {
urlsRef.current = urls;
}, [urls]);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
if (!inWechat) return;
void weixinSdk.init().catch(() => {});
}, [inWechat]);
function showUploadError(text: string) {
setError(text);
toastError(text);
}
async function uploadFiles(files: File[]) {
const current = urlsRef.current;
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) {
const next = [...urlsRef.current, ...appended];
urlsRef.current = next;
onChangeRef.current?.(next);
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>
+12 -3
View File
@@ -5,7 +5,10 @@ export type StoreDraftForm = {
city: string;
district: string;
name: string;
/** 门店登录手机号(老板主账号) */
phone: string;
/** 对外联系电话(店长);可与登录号不同 */
contactPhone: string;
address: string;
/** 门店坐标(定位或地理编码) */
latitude: string;
@@ -51,6 +54,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({
cityId: '',
name: '',
phone: '',
contactPhone: '',
address: '',
latitude: '',
longitude: '',
@@ -102,6 +106,9 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
...raw,
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
cityId: String(raw.cityId ?? base.cityId),
phone: String(raw.phone ?? base.phone),
contactPhone:
String(raw.contactPhone ?? '').trim() || String(raw.phone ?? '').trim() || base.contactPhone,
latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude,
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
openTime: String(raw.openTime ?? base.openTime),
@@ -236,14 +243,16 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
export function validateStoreStep3(
form: Pick<
StoreDraftForm,
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone'
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' | 'contactPhone'
>,
): string | null {
if (!form.bankAccountName.trim()) return '请填写户主姓名';
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
if (!form.bankBranch.trim()) return '请填写开户支行';
if (!form.phone.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
if (!form.phone.trim()) return '请填写门店登录手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店登录手机号须为11位手机号';
if (!form.contactPhone.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.contactPhone.trim())) return '联系电话须为11位手机号';
return null;
}
+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;
}
+18 -2
View File
@@ -2,7 +2,7 @@ export type PartnerStoreAuditStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | stri
export function storeAuditLabel(auditStatus?: string | null): string {
const s = String(auditStatus || 'APPROVED').toUpperCase();
if (s === 'PENDING') return '待总部审核';
if (s === 'PENDING') return '待审核';
if (s === 'REJECTED') return '审核驳回';
if (s === 'APPROVED') return '审核通过';
return auditStatus || '—';
@@ -21,10 +21,26 @@ export function storeStatusLabel(status: string): string {
const s = String(status).toUpperCase();
if (s === 'OPEN') return '营业中';
if (s === 'PAUSED') return '临时闭店';
if (s === 'CLOSED') return '永久闭';
if (s === 'CLOSED') return '永久闭';
return status;
}
/** 列表右上角统一状态:审核未通过优先于营业状态 */
export function storeListBadge(store: {
status?: unknown;
auditStatus?: unknown;
}): { label: string; pillClass: string } {
const audit = String(store.auditStatus || 'APPROVED').toUpperCase();
if (audit === 'PENDING') {
return { label: '待审核', pillClass: storeAuditPillClass('PENDING') };
}
if (audit === 'REJECTED') {
return { label: '审核驳回', pillClass: storeAuditPillClass('REJECTED') };
}
const status = String(store.status || '').toUpperCase();
return { label: storeStatusLabel(status), pillClass: storeStatusPillClass(status) };
}
export function storeStatusPillClass(status: string): string {
const s = String(status).toUpperCase();
if (s === 'OPEN') return 'partner-status-pill--open';
+147 -64
View File
@@ -5,8 +5,10 @@ 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 { fetchClientConfig } from '../lib/wechat-auth';
import { toastError, toastSuccess } from '../lib/toast';
import { resolveRegionBinding } from '../lib/china-region';
@@ -40,8 +42,6 @@ import {
validateStoreStep3,
patchEnvPhotoAt,
addEnvPhotoSlot,
MIN_ENV_PHOTO_COUNT,
} from '../lib/storeDraft';
import StorePackagesForm from '../components/StorePackagesForm';
@@ -49,6 +49,9 @@ import { normalizePackageFormItems, validatePackageFormItems } from '../lib/stor
const STEPS = ['基本信息', '照片上传', '结算资质', '门店套餐'] as const;
const DEFAULT_PARTNER_ONBOARD_CS_HINT =
'使用问题、提现问题等随时可联系【杜康好客】客服';
type StoreCategoryNode = {
id: string;
name: string;
@@ -57,6 +60,7 @@ type StoreCategoryNode = {
type FieldErrors = {
phone?: string;
contactPhone?: string;
};
@@ -98,6 +102,10 @@ export default function StoreCreatePage() {
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitting, setSubmitting] = useState(false);
const [csAdded, setCsAdded] = useState(false);
const [csQrUrl, setCsQrUrl] = useState<string | null>(null);
const [csHint, setCsHint] = useState(DEFAULT_PARTNER_ONBOARD_CS_HINT);
const [csConfigLoading, setCsConfigLoading] = useState(false);
const [cities, setCities] = useState<OpenCityOption[]>([]);
@@ -149,6 +157,29 @@ export default function StoreCreatePage() {
});
}, [step]);
useEffect(() => {
if (step !== 4) return;
let cancelled = false;
setCsConfigLoading(true);
void fetchClientConfig()
.then((cfg) => {
if (cancelled) return;
setCsQrUrl((cfg.partnerOnboardCsQrUrl ?? '').trim() || null);
setCsHint((cfg.partnerOnboardCsHint ?? '').trim() || DEFAULT_PARTNER_ONBOARD_CS_HINT);
})
.catch(() => {
if (cancelled) return;
setCsQrUrl(null);
setCsHint(DEFAULT_PARTNER_ONBOARD_CS_HINT);
})
.finally(() => {
if (!cancelled) setCsConfigLoading(false);
});
return () => {
cancelled = true;
};
}, [step]);
useEffect(() => {
void fetchPartnerCities()
@@ -238,9 +269,17 @@ export default function StoreCreatePage() {
setSubmitError('');
if ('phone' in patch) {
if ('phone' in patch || 'contactPhone' in patch) {
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
setFieldErrors((prev) => ({
...prev,
...('phone' in patch ? { phone: undefined } : {}),
...('contactPhone' in patch ? { contactPhone: undefined } : {}),
}));
}
@@ -250,22 +289,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);
@@ -332,7 +355,9 @@ export default function StoreCreatePage() {
const msg = validateStoreStep3(form);
if (msg) {
if (isPhoneValidationMessage(msg)) {
setFieldErrors({ phone: msg });
setFieldErrors(
msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
);
reportStepError(msg);
return;
}
@@ -348,12 +373,23 @@ export default function StoreCreatePage() {
async function submit(skipPackages = false) {
const qr = (csQrUrl ?? '').trim();
if (!qr) {
reportFormError('客服二维码暂未配置,请联系总部');
return;
}
if (!csAdded) {
reportFormError('请先勾选「我已添加【杜康好客】客服」');
return;
}
const msg = validateStoreStep3(form);
if (msg) {
if (isPhoneValidationMessage(msg)) {
setFieldErrors({ phone: msg });
setFieldErrors(
msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
);
return;
}
reportFormError(msg);
@@ -458,6 +494,8 @@ export default function StoreCreatePage() {
phone: form.phone.trim(),
contactPhone: form.contactPhone.trim() || form.phone.trim(),
district: form.district.trim(),
address: form.address.trim(),
@@ -539,6 +577,7 @@ export default function StoreCreatePage() {
const progress = step === 1 ? 0 : step === 2 ? 33 : step === 3 ? 66 : 100;
const canSubmitOnboard = !!csQrUrl?.trim() && csAdded && !submitting && !csConfigLoading;
const nextDisabled = submitting;
@@ -957,46 +996,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>
@@ -1137,7 +1145,43 @@ export default function StoreCreatePage() {
<p className="label-md text-muted" style={{ marginTop: 8 }}>
</p>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">phone_in_talk</span>
<input
type="tel"
placeholder="店长或对外可拨打号码"
value={form.contactPhone}
onChange={(e) => patchForm({ contactPhone: e.target.value })}
/>
</div>
{fieldErrors.contactPhone && (
<p className="partner-field-error" role="alert">{fieldErrors.contactPhone}</p>
)}
<p className="label-md text-muted" style={{ marginTop: 8 }}>
使
</p>
@@ -1150,6 +1194,37 @@ export default function StoreCreatePage() {
{step === 4 && (
<>
<section className="partner-form-card partner-onboard-cs-card">
<div className="partner-section-title">
<div className="partner-section-bar" />
<h2 className="headline-md"></h2>
</div>
<p className="label-md text-muted" style={{ marginBottom: 16, lineHeight: 1.5 }}>
{csHint}
</p>
{csConfigLoading ? (
<p className="body-md text-muted"></p>
) : csQrUrl ? (
<img
className="partner-onboard-cs-qr"
src={csQrUrl}
alt="杜康好客企微客服二维码"
/>
) : (
<p className="partner-form-error" role="alert">
</p>
)}
<label className="partner-onboard-cs-check">
<input
type="checkbox"
checked={csAdded}
disabled={!csQrUrl || submitting}
onChange={(e) => setCsAdded(e.target.checked)}
/>
<span></span>
</label>
</section>
<section className="partner-form-card">
<div className="partner-section-title">
<div className="partner-section-bar" />
@@ -1195,13 +1270,21 @@ export default function StoreCreatePage() {
) : (
<>
<button type="button" className="partner-btn-outline" onClick={() => void submit(true)} disabled={submitting}>
<button
type="button"
className="partner-btn-outline"
onClick={() => void submit(true)}
disabled={!canSubmitOnboard}
>
</button>
<button type="button" className="partner-btn-primary" onClick={() => void submit(false)} disabled={submitting}>
<button
type="button"
className="partner-btn-primary"
onClick={() => void submit(false)}
disabled={!canSubmitOnboard}
>
{submitting ? '提交中…' : '提交'}
</button>
</>
+32 -24
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,
@@ -42,7 +43,8 @@ export default function StoreDetailPage() {
const [loadError, setLoadError] = useState('');
const [form, setForm] = useState({
name: '',
phone: '',
loginPhone: '',
contactPhone: '',
address: '',
intro: '',
benefitUsageRule: '',
@@ -63,7 +65,8 @@ export default function StoreDetailPage() {
setStore(data);
setForm({
name: String(data.name || ''),
phone: String(data.phone || ''),
loginPhone: String(data.phone || ''),
contactPhone: String(data.contactPhone || data.phone || ''),
address: String(data.address || ''),
intro: String(data.intro || ''),
benefitUsageRule: (() => {
@@ -163,7 +166,7 @@ export default function StoreDetailPage() {
method: 'PUT',
body: JSON.stringify({
name: form.name.trim(),
phone: form.phone.trim(),
contactPhone: form.contactPhone.trim(),
address: form.address.trim(),
intro: form.intro.trim(),
benefitUsageRule: form.benefitUsageRule.trim() || null,
@@ -345,12 +348,30 @@ export default function StoreDetailPage() {
<label></label>
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
</div>
<div className="partner-field">
<label></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">lock</span>
<input disabled type="tel" value={form.loginPhone} readOnly />
</div>
<p className="label-md text-muted" style={{ marginTop: 8 }}>
</p>
</div>
<div className="partner-field">
<label></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">call</span>
<input disabled={readOnly} type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
<span className="material-symbols-outlined">phone_in_talk</span>
<input
disabled={readOnly}
type="tel"
value={form.contactPhone}
onChange={(e) => setForm({ ...form, contactPhone: e.target.value })}
/>
</div>
<p className="label-md text-muted" style={{ marginTop: 8 }}>
使
</p>
</div>
<div className="partner-field">
<label></label>
@@ -409,26 +430,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"
+119 -67
View File
@@ -8,9 +8,7 @@ import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAcce
import {
canPartnerOpenStore,
storeAuditLabel,
storeAuditPillClass,
storeStatusLabel,
storeStatusPillClass,
storeListBadge,
type StoreStatusValue,
} from '../lib/storeStatus';
import { usePartnerPageView } from '../lib/usePageView';
@@ -20,10 +18,10 @@ type StatusFilter = 'ALL' | StoreStatusValue | 'PENDING_AUDIT' | 'REJECTED';
const FILTERS: { key: StatusFilter; label: string }[] = [
{ key: 'ALL', label: '全部' },
{ key: 'OPEN', label: '营业中' },
{ key: 'PAUSED', label: '时闭店' },
{ key: 'PAUSED', label: '时闭店' },
{ key: 'PENDING_AUDIT', label: '待审核' },
{ key: 'REJECTED', label: '已驳回' },
{ key: 'CLOSED', label: '关闭' },
{ key: 'CLOSED', label: '永久闭店' },
];
export default function StoreListPage() {
@@ -46,7 +44,10 @@ export default function StoreListPage() {
}, []);
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
if (!isLoggedIn()) {
navigate('/login');
return;
}
void loadStores();
}, [navigate, loadStores]);
@@ -54,7 +55,9 @@ export default function StoreListPage() {
document.title = canMutate ? '门店管理' : '我的门店';
}, [canMutate]);
const filtered = useMemo(() => stores.filter((s) => {
const filtered = useMemo(
() =>
stores.filter((s) => {
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
const status = String(s.status).toUpperCase();
@@ -63,11 +66,17 @@ export default function StoreListPage() {
else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED';
else if (filter !== 'ALL') matchStatus = status === filter;
return matchQ && matchStatus;
}), [stores, q, filter]);
}),
[stores, q, filter],
);
async function updateStatus(storeId: string, next: StoreStatusValue, auditStatus?: string) {
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
setError(auditStatus === 'REJECTED' ? '门店审核未通过,请查看驳回原因并修改后重新提交' : '门店尚在总部审核中,通过后方可开门');
setError(
auditStatus === 'REJECTED'
? '门店审核未通过,请查看驳回原因并修改后重新提交'
: '门店尚在总部审核中,通过后方可开门',
);
return;
}
if (next === 'CLOSED') {
@@ -82,7 +91,8 @@ export default function StoreListPage() {
body: JSON.stringify({ status: next }),
});
await loadStores();
if (next === 'OPEN') toastSuccess('开店成功');
if (next === 'OPEN') toastSuccess('已营业');
if (next === 'PAUSED') toastSuccess('已临时闭店');
} catch {
/* request 已 toast */
} finally {
@@ -102,6 +112,7 @@ export default function StoreListPage() {
body: JSON.stringify({ status: 'CLOSED' }),
});
await loadStores();
toastSuccess('已永久闭店');
} catch {
/* request 已 toast */
} finally {
@@ -111,19 +122,33 @@ export default function StoreListPage() {
return (
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
{error && (
<p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>
{error}
</p>
)}
<div className="partner-sticky-filter">
<div className="partner-search">
<span className="material-symbols-outlined">search</span>
<input placeholder="搜索门店名称/地址" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="partner-chips">
<div className="partner-store-filter-row">
<label className="partner-store-filter-label" htmlFor="partner-store-status-filter">
</label>
<select
id="partner-store-status-filter"
className="partner-store-filter-select"
value={filter}
onChange={(e) => setFilter(e.target.value as StatusFilter)}
>
{FILTERS.map((f) => (
<button key={f.key} type="button" className={`partner-chip${filter === f.key ? ' active' : ''}`} onClick={() => setFilter(f.key)}>
<option key={f.key} value={f.key}>
{f.label}
</button>
</option>
))}
</select>
</div>
</div>
@@ -145,87 +170,114 @@ export default function StoreListPage() {
const dim = currentStatus === 'CLOSED';
const storeName = String(s.name || '未命名门店');
const busy = updatingId === storeId;
const canOpen = canPartnerOpenStore(auditStatus);
const badge = storeListBadge(s);
const switchOn = currentStatus === 'OPEN';
const switchDisabled =
busy ||
currentStatus === 'CLOSED' ||
auditStatus === 'PENDING' ||
auditStatus === 'REJECTED';
return (
<div key={storeId} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
<Link to={`/stores/${storeId}`} className="partner-store-card-hit" style={{ color: 'inherit', textDecoration: 'none' }}>
<Link
to={`/stores/${storeId}`}
className="partner-store-card-hit"
style={{ color: 'inherit', textDecoration: 'none' }}
>
<div className="partner-store-card-header">
<div>
<p className="label-md text-muted" style={{ marginBottom: 2 }}></p>
<h3 className="headline-md">{storeName}</h3>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(s.address || s.district || '')}</p>
{auditStatus !== 'APPROVED' && (
<p className="label-md" style={{ marginTop: 8, color: auditStatus === 'REJECTED' ? 'var(--color-heritage-red)' : 'var(--color-secondary)' }}>
{storeAuditLabel(auditStatus)}
{auditStatus === 'REJECTED' && s.rejectReason ? `${String(s.rejectReason)}` : ''}
<div className="partner-store-card-main">
<p className="label-md text-muted" style={{ marginBottom: 2 }}>
</p>
)}
<h3 className="headline-md">{storeName}</h3>
<p className="label-md text-muted" style={{ marginTop: 4 }}>
{String(s.address || s.district || '')}
</p>
{auditStatus === 'REJECTED' && s.rejectReason ? (
<p
className="label-md"
style={{ marginTop: 8, color: 'var(--color-heritage-red)' }}
>
{storeAuditLabel(auditStatus)}{String(s.rejectReason)}
</p>
) : null}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
{auditStatus !== 'APPROVED' ? (
<span className={`partner-status-pill ${storeAuditPillClass(auditStatus)}`}>
{storeAuditLabel(auditStatus)}
<span className={`partner-status-pill partner-store-card-badge ${badge.pillClass}`}>
{badge.label}
</span>
) : (
<span className={`partner-status-pill ${storeStatusPillClass(currentStatus)}`}>
{storeStatusLabel(currentStatus)}
</span>
)}
</div>
</div>
</Link>
{canMutate && (
<div className="partner-store-card-actions">
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px' }}
disabled={busy || currentStatus === 'CLOSED' || currentStatus === 'PAUSED' || auditStatus === 'PENDING'}
onClick={() => void updateStatus(storeId, 'PAUSED', auditStatus)}
{canMutate ? (
<div className="partner-store-card-actions partner-store-card-actions--v3416">
{currentStatus !== 'CLOSED' ? (
<>
<label
className={`partner-store-switch${switchDisabled ? ' partner-store-switch--disabled' : ''}`}
>
</button>
<input
type="checkbox"
role="switch"
checked={switchOn}
disabled={switchDisabled}
aria-label={switchOn ? '营业中,点击临时闭店' : '临时闭店,点击营业'}
onChange={() => {
const next: StoreStatusValue = switchOn ? 'PAUSED' : 'OPEN';
void updateStatus(storeId, next, auditStatus);
}}
/>
<span className="partner-store-switch-track" aria-hidden />
<span className="partner-store-switch-text">
{switchOn ? '开启(营业中)' : '关闭(临时闭店)'}
</span>
</label>
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px', borderColor: 'var(--color-subtle-gray)', color: 'var(--color-subtle-gray)' }}
disabled={busy || currentStatus === 'CLOSED'}
className="partner-store-close-btn"
disabled={busy}
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
>
</button>
{currentStatus === 'PAUSED' && (
<button
type="button"
className="btn btn-outline"
style={{ fontSize: 12, padding: '8px 12px' }}
disabled={busy || !canOpen}
onClick={() => void updateStatus(storeId, 'OPEN', auditStatus)}
</>
) : null}
<Link
to={`/stores/${storeId}`}
className="partner-menu-icon"
style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}
>
{canOpen ? '开门营业' : '待审核通过'}
</button>
)}
<Link to={`/stores/${storeId}`} className="partner-menu-icon" style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit</span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
edit
</span>
</Link>
</div>
)}
) : null}
</div>
);
})}
{closeTarget && (
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseTarget(null)}>
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<div
className="partner-ship-modal-backdrop"
role="presentation"
onClick={() => setCloseTarget(null)}
>
<div
className="partner-ship-modal"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
<h3 className="headline-md" style={{ marginBottom: 8 }}>
</h3>
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
</p>
<div className="partner-ship-actions">
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
</button>
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
</button>
</div>
</div>
@@ -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 : '加载失败'))
+161
View File
@@ -1960,6 +1960,134 @@ nav.app-tabbar .app-tabbar-label {
color: #fff;
}
.partner-store-filter-row {
display: flex;
align-items: center;
gap: 10px;
}
.partner-store-filter-label {
flex-shrink: 0;
font-family: var(--font-label);
font-size: 13px;
font-weight: 500;
color: var(--color-on-surface-variant);
}
.partner-store-filter-select {
flex: 1;
height: 40px;
padding: 0 12px;
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-md);
background: var(--color-surface-container-low);
font-size: 14px;
color: var(--color-ink-black);
}
.partner-store-filter-select:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(166, 29, 36, 0.2);
}
.partner-store-card-main {
flex: 1;
min-width: 0;
padding-right: 8px;
}
.partner-store-card-badge {
flex-shrink: 0;
white-space: nowrap;
}
.partner-store-card-actions--v3416 {
align-items: center;
flex-wrap: wrap;
}
.partner-store-card-actions--v3416 > .partner-store-switch {
flex: 1 1 auto;
min-width: 140px;
}
.partner-store-card-actions--v3416 > .partner-store-close-btn {
flex: 0 0 auto;
}
.partner-store-card-actions--v3416 > .partner-menu-icon {
flex: 0 0 auto;
margin-left: auto;
}
.partner-store-switch {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-family: var(--font-label);
font-size: 12px;
color: var(--color-on-surface-variant);
user-select: none;
}
.partner-store-switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.partner-store-switch-track {
position: relative;
width: 44px;
height: 24px;
border-radius: 999px;
background: var(--color-surface-container-highest);
transition: background 0.2s ease;
flex-shrink: 0;
}
.partner-store-switch-track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 0.2s ease;
}
.partner-store-switch input:checked + .partner-store-switch-track {
background: var(--color-success-green);
}
.partner-store-switch input:checked + .partner-store-switch-track::after {
transform: translateX(20px);
}
.partner-store-switch--disabled {
opacity: 0.45;
cursor: not-allowed;
}
.partner-store-close-btn {
flex: 0 0 auto !important;
padding: 8px 12px !important;
border: 1px solid rgba(166, 29, 36, 0.35);
background: transparent;
color: var(--color-heritage-red);
font-weight: 500;
}
.partner-store-close-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.partner-store-card {
background: var(--color-card);
border-radius: var(--radius-md);
@@ -2061,6 +2189,39 @@ nav.app-tabbar .app-tabbar-label {
margin: 0 var(--space-page) var(--space-md);
}
.partner-onboard-cs-card {
text-align: center;
}
.partner-onboard-cs-qr {
display: block;
width: min(220px, 70vw);
height: auto;
margin: 0 auto 16px;
border-radius: 8px;
background: #fff;
border: 1px solid var(--color-surface-container);
}
.partner-onboard-cs-check {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
font-family: var(--font-label);
font-size: 14px;
color: var(--color-ink-black);
cursor: pointer;
text-align: left;
}
.partner-onboard-cs-check input {
width: 18px;
height: 18px;
flex-shrink: 0;
}
/* ── Store detail ── */
.partner-detail-page {
padding-bottom: 96px;
+2 -1
View File
@@ -12,9 +12,10 @@ export default defineConfig({
},
},
server: {
host: true,
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;
}
+2 -1
View File
@@ -12,7 +12,8 @@ export default defineConfig({
},
},
server: {
host: true,
port: 5174,
proxy: { '/api': 'http://localhost:3000' },
proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010' },
},
});
+2 -1
View File
@@ -12,8 +12,9 @@ export default defineConfig({
},
},
server: {
host: true,
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: '再想想',
});
@@ -108,6 +108,13 @@ function formatRedeemTime(input?: string | null) {
return formatShanghaiDateTime(input);
}
function formatPackagePriceYuan(price: string | number) {
const n = typeof price === 'number' ? price : Number(price);
if (!Number.isFinite(n)) return '0';
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
return n.toFixed(2).replace(/\.?0+$/, '');
}
function formatRedeemAmountYuan(amount: number | string) {
const n = typeof amount === 'number' ? amount : Number(amount);
if (!Number.isFinite(n)) return '0';
@@ -419,7 +426,12 @@ export default function StoreDetailPage() {
className="store-detail-package-list-item"
onClick={() => openPackageDetail(index)}
>
<View className="store-detail-package-list-row">
<Text className="store-detail-package-list-title">{pkg.name}</Text>
<Text className="store-detail-package-list-price">
&nbsp;&nbsp;&nbsp;&nbsp;¥{formatPackagePriceYuan(pkg.price)}
</Text>
</View>
</View>
))}
</View>
@@ -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;
+39 -14
View File
@@ -281,13 +281,36 @@
padding-bottom: 0;
}
.store-detail-package-list-title {
/* 名称在左可换行;价格始终贴该行最右侧 */
.store-detail-package-list-row {
/* display: flex;
flex-wrap: wrap;
align-items: flex-start;
column-gap: 12px;
row-gap: 4px; */
display: block;
width: 100%;
}
.store-detail-package-list-title {
min-width: 0;
font-family: var(--font-headline);
font-size: 15px;
line-height: 1.5;
font-weight: 500;
color: var(--color-on-surface);
word-break: break-word;
display: inline;
}
.store-detail-package-list-price {
display: inline;
margin-left: auto;
font-size: 15px;
line-height: 1.5;
font-weight: 600;
color: var(--color-heritage-red, #a61d24);
text-align: right;
}
.store-detail-package-list-item:active {
@@ -315,15 +338,18 @@
padding: 8px 16px 4px 16px;
}
/* 与门店详情套餐列表一致:名称在左可换行;价格始终贴该行最右侧 */
.store-package-detail-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
align-items: flex-end;
column-gap: 16px;
row-gap: 4px;
width: 100%;
}
.store-package-detail-title {
flex: 1;
/* flex: 1 1 10em; */
min-width: 0;
font-family: var(--font-headline);
font-size: 20px;
@@ -334,13 +360,14 @@
}
.store-package-detail-price {
flex-shrink: 0;
font-size: 24px;
/* flex: 0 0 auto; */
margin-left: auto;
font-size: 20px;
font-weight: 700;
line-height: 1.35;
color: #a61d24;
line-height: 1.4;
color: var(--color-heritage-red, #a61d24);
text-align: right;
white-space: nowrap;
}
.store-package-detail-store {
@@ -352,7 +379,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 +387,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;
}
+6 -1
View File
@@ -1,4 +1,4 @@
/** HQ 权限目录(权限分配页勾选源) */
/** HQ 权限目录(权限分配页勾选源) */
export const HQ_PERMISSION_CATALOG = [
{ key: 'dashboard', label: '概览', group: '业务' },
{ key: 'users', label: '用户管理', group: '业务' },
@@ -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: '危险操作' },
@@ -29,6 +30,7 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'system_settings_sms', label: '短信', group: '系统设置' },
{ key: 'system_settings_wechat', label: '微信', group: '系统设置' },
{ key: 'system_settings_wechat_mini', label: '微信小程序', group: '系统设置' },
{ key: 'system_settings_wechat_mini_share', label: '小程序分享配置', group: '系统设置' },
{ key: 'system_settings_oss', label: '对象存储 OSS', group: '系统设置' },
{ key: 'system_settings_app', label: '应用链接', group: '系统设置' },
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
@@ -59,6 +61,7 @@ export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = {
sms: 'system_settings_sms',
wechat: 'system_settings_wechat',
wechat_mini: 'system_settings_wechat_mini',
wechat_mini_share: 'system_settings_wechat_mini_share',
oss: 'system_settings_oss',
app: 'system_settings_app',
deploy: 'system_settings_deploy',
@@ -116,9 +119,11 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
'llm_configs',
'knowledge_bases',
'dev_plan',
'test_whitelist',
'resources',
'logs',
'system_settings_wechat_mini',
'system_settings_wechat_mini_share',
],
FINANCE: [
'dashboard',
@@ -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?: {
+4
View File
@@ -64,6 +64,10 @@ export type ClientRuntimeConfig = {
qualificationDisclosureUrl?: string;
/** 总部客服电话 */
customerServicePhone?: string;
/** 合伙人入驻:企微客服二维码图片 URL */
partnerOnboardCsQrUrl?: string | null;
/** 合伙人入驻:企微客服提示文案 */
partnerOnboardCsHint?: string | null;
/** 小程序各场景分享文案/图 */
share?: MiniShareRuntime;
};
+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=
+42
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")
}
@@ -1226,7 +1248,10 @@ model Store {
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
categoryId BigInt? @map("category_id") @db.UnsignedBigInt
name String @db.VarChar(128)
/// 门店主账号登录手机号(老板);与 StoreAccount.phone 对齐
phone String @db.VarChar(20)
/// 对外联系电话(店长等);C 端拨号展示用;空则回退 phone
contactPhone String? @map("contact_phone") @db.VarChar(20)
province String @db.VarChar(32)
cityName String @map("city_name") @db.VarChar(32)
district String @db.VarChar(32)
@@ -1253,6 +1278,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 +1303,8 @@ model Store {
@@index([cityId, status])
@@index([partnerAccountId])
@@index([auditStatus, createdAt])
@@index([isTest])
@@index([sortOrder])
@@map("store_store")
}
@@ -1300,6 +1331,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 +1377,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 +1389,7 @@ model StoreAccount {
withdrawRequests StoreWithdrawRequest[]
@@index([parentAccountId])
@@index([isTest])
@@map("store_account")
}
@@ -1432,6 +1468,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 +1494,7 @@ model Order {
@@index([gpsCity])
@@index([fulfillmentWarehouseId])
@@index([proxyPartnerAccountId])
@@index([isTest])
@@map("user_order")
}
@@ -1548,6 +1587,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 +1601,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,
};
}
}
@@ -44,11 +44,36 @@ export function mapOrderCompat<T extends OrderLike & {
};
}
export function mapStoreCompat<T extends { coverResource?: { url?: string } | null }>(store: T) {
return {
/** 对外联系电话;未单独配置时回退登录手机号 */
export function resolveStoreContactPhone(store: {
phone?: string | null;
contactPhone?: string | null;
}): string {
const contact = String(store.contactPhone ?? '').trim();
if (contact) return contact;
return String(store.phone ?? '').trim();
}
export function mapStoreCompat<
T extends {
coverResource?: { url?: string } | null;
phone?: string | null;
contactPhone?: string | null;
},
>(store: T, opts?: { /** C 端:phone 字段对外可拨打号码 */ publicDial?: boolean }) {
const contactPhone = resolveStoreContactPhone(store) || null;
const mapped = {
...store,
coverUrl: store.coverResource?.url ?? null,
contactPhone,
};
if (opts?.publicDial) {
return {
...mapped,
phone: contactPhone || store.phone || null,
};
}
return mapped;
}
export function mapStatusLogCompat(events: CommonEvent[]) {
@@ -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 格式;客户端低于此版本时提示更新',
},
{
@@ -213,6 +213,23 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
placeholder: '13203801799',
description: 'C 端联系客服拨号号码',
},
{
key: 'PARTNER_ONBOARD_CS_QR_URL',
label: '合伙人入驻 · 企微客服二维码',
group: G.wechat_mini,
type: 'image',
requiresRestart: false,
description: '合伙人 H5 录入门店提交前展示;未配置时禁止提交入驻',
},
{
key: 'PARTNER_ONBOARD_CS_HINT',
label: '合伙人入驻 · 客服提示文案',
group: G.wechat_mini,
type: 'string',
requiresRestart: false,
placeholder: '使用问题、提现问题等随时可联系【杜康好客】客服',
description: '二维码下方说明;留空用默认文案',
},
{
key: 'MOCK_SMS_FIXED_CODE',
label: 'Mock 短信固定验证码',
@@ -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)) {
+49 -6
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,16 +75,24 @@ 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 = Number(process.env.PORT || 3010);
const host = process.env.HOST || '0.0.0.0';
const cfg = loadAppConfig();
const smsMode = cfg.mockSms ? 'MOCK' : 'ALIYUN';
console.log(`[config] NODE_ENV=${process.env.NODE_ENV} MOCK_SMS=${cfg.mockSms} SMS=${smsMode}`);
await app.listen(port);
console.log(`dukang-api listening on http://localhost:${port}/api/v1`);
await app.listen(port, host);
console.log(`dukang-api listening on http://${host}:${port}/api/v1`);
}
bootstrap();
@@ -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);
}
}
@@ -37,6 +37,10 @@ export class ClientConfigController {
brandLogoMarkUrl: brand.brandLogoMarkUrl,
qualificationDisclosureUrl: brand.qualificationDisclosureUrl,
customerServicePhone: brand.customerServicePhone,
partnerOnboardCsQrUrl: (env.PARTNER_ONBOARD_CS_QR_URL ?? '').trim() || null,
partnerOnboardCsHint:
(env.PARTNER_ONBOARD_CS_HINT ?? '').trim() ||
'使用问题、提现问题等随时可联系【杜康好客】客服',
share,
};
}
@@ -5,7 +5,6 @@ import { AlertService } from '../../common/alert/alert.service';
import type { AlertLevel } from '../../common/alert/alert.constants';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import type { ReportClientErrorDto } from './dto/client-error.dto';
import { SupportTicketService } from './support-ticket.service';
const WECOM_LEVELS = new Set(['fatal', 'error']);
@@ -16,7 +15,6 @@ export class ClientErrorService {
constructor(
private readonly prisma: PrismaService,
private readonly alert: AlertService,
private readonly supportTicket: SupportTicketService,
) {}
async report(dto: ReportClientErrorDto, user?: AuthUser, headerClientApp?: string) {
@@ -109,25 +107,6 @@ export class ClientErrorService {
);
}
if (dto.category === 'validation_error') {
const apiPath =
dto.extra && typeof dto.extra.url === 'string' ? dto.extra.url.slice(0, 256) : undefined;
void this.supportTicket
.createFromClientValidation({
clientApp,
message,
pagePath,
apiPath,
actorLabel:
user?.actorId != null ? `${user.actorType}:${String(user.actorId)}` : undefined,
})
.catch((e) => {
this.logger.warn(
`auto support ticket failed: ${e instanceof Error ? e.message : String(e)}`,
);
});
}
return { ok: true };
}
}
@@ -38,17 +38,8 @@ function generateSupportTicketNo() {
return `ST${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
}
const AUTO_VALIDATION_TICKET_APPS = new Set([
'USER_H5',
'USER_MINI',
'SHOP_H5',
'PARTNER_H5',
]);
@Injectable()
export class SupportTicketService {
private systemCreatorCache: { id: bigint; name: string } | null = null;
constructor(
private readonly prisma: PrismaService,
private readonly alert: AlertService,
@@ -105,66 +96,6 @@ export class SupportTicketService {
return serializeBigInt(mapSupportTicketRow(ticket));
}
/** 客户端 400 验证错误自动建单(1 小时内同标题去重) */
async createFromClientValidation(input: {
clientApp: string;
message: string;
pagePath?: string;
apiPath?: string;
actorLabel?: string;
}) {
if (!AUTO_VALIDATION_TICKET_APPS.has(input.clientApp)) {
return { skipped: true as const, reason: 'unsupported_app' as const };
}
const title = `[客户端验证] ${input.clientApp}${input.pagePath ? ` · ${input.pagePath}` : ''} · ${input.message.slice(0, 60)}`;
const oneHourAgo = new Date(Date.now() - 3600_000);
const existing = await this.prisma.commonSupportTicket.findFirst({
where: { title, createdAt: { gte: oneHourAgo } },
select: { id: true },
});
if (existing) {
return { skipped: true as const, ticketId: existing.id.toString() };
}
const creator = await this.resolveSystemCreator();
const content = [
`端:${input.clientApp}`,
input.pagePath ? `页面:${input.pagePath}` : null,
input.apiPath ? `接口:${input.apiPath}` : null,
input.actorLabel ? `用户:${input.actorLabel}` : null,
'',
input.message,
]
.filter(Boolean)
.join('\n');
const ticket = await this.create(
{
ticketType: 'BUG',
title,
content,
remark: '客户端验证错误自动上报',
},
creator,
);
return { skipped: false as const, ticketId: String(ticket.id) };
}
private async resolveSystemCreator() {
if (this.systemCreatorCache) return this.systemCreatorCache;
const account = await this.prisma.hqAccount.findFirst({
where: { adminRole: 'SUPER_ADMIN', status: 'ACTIVE' },
orderBy: { id: 'asc' },
select: { id: true },
});
if (!account) {
throw new BadRequestException('未找到系统管理员账号,无法自动创建工单');
}
this.systemCreatorCache = { id: account.id, name: '系统自动' };
return this.systemCreatorCache;
}
async update(id: bigint, dto: UpdateSupportTicketDto) {
const ticket = await this.getOrThrow(id);
if (ticket.status !== 'PENDING_REVIEW') {
@@ -83,18 +83,13 @@ export class WechatController {
return this.wechat.createJssdkConfig(pageUrl, actorRef);
}
/**
* hash code/state
* iOS document URLOAuth 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: {
@@ -143,8 +146,10 @@ export class AdminStoresService {
visibilityPhones: visibilityPhones.map((p) => p.phone),
partner: store.partnerAccount,
account: store.bindings[0]?.storeAccount ?? null,
/** 门店端登录手机号(store_account.phone与 store.phone 对齐 */
/** 门店端登录手机号(store_account.phone与 store.phone 对齐 */
loginPhone: store.bindings[0]?.storeAccount?.phone ?? store.phone,
/** 对外联系电话;空则回退登录号 */
contactPhone: store.contactPhone?.trim() || store.phone,
bindings: undefined,
media,
audits,
@@ -272,29 +277,26 @@ export class AdminStoresService {
if (dto.phone !== undefined) {
const normalizedPhone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
throw new BadRequestException('请输入正确的手机号');
throw new BadRequestException('请输入正确的登录手机号');
}
}
if (dto.contactPhone !== undefined) {
const contact = dto.contactPhone.trim();
if (contact && !/^1[3-9]\d{9}$/.test(contact)) {
throw new BadRequestException('请输入正确的联系电话');
}
}
const normalizedPhone = dto.phone !== undefined ? dto.phone.trim() : undefined;
const normalizedContactPhone =
dto.contactPhone !== undefined ? dto.contactPhone.trim() || null : undefined;
if (dto.visibilityWhitelistEnabled !== undefined || dto.visibilityPhones !== undefined) {
const nextEnabled =
dto.visibilityWhitelistEnabled !== undefined
? !!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 =
@@ -304,7 +306,7 @@ export class AdminStoresService {
const needAccountSync =
normalizedPhone !== undefined || dto.name !== undefined || bankTouched;
// 登录凭证在 store_account.phone必须与门店展示手机号同步
// 登录凭证在 store_account.phone与 store.phone(老板登录号)同步,不与对外联系电话绑定
let primaryBinding: {
storeAccount: { id: bigint; phone: string; name: string } | null;
} | null = null;
@@ -354,6 +356,9 @@ export class AdminStoresService {
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(normalizedPhone !== undefined ? { phone: normalizedPhone } : {}),
...(normalizedContactPhone !== undefined
? { contactPhone: normalizedContactPhone }
: {}),
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
...(dto.benefitUsageRule !== undefined
? { benefitUsageRule: normalizeStoreOptionalText(dto.benefitUsageRule) }
@@ -372,25 +377,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 +414,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,10 +566,18 @@ 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 contactPhoneRaw = dto.contactPhone?.trim() || normalizedPhone;
if (!/^1[3-9]\d{9}$/.test(contactPhoneRaw)) {
throw new BadRequestException('请输入正确的联系电话');
}
const store = await this.prisma.store.create({
@@ -519,6 +588,7 @@ export class AdminStoresService {
categoryId,
name: dto.name,
phone: normalizedPhone,
contactPhone: contactPhoneRaw,
province: dto.province ?? city.province,
cityName: dto.city ?? city.name,
district: dto.district ?? '',
@@ -531,18 +601,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 +626,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 +811,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({

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