Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0181af09c5 | |||
| 9cd0c9508a | |||
| 291137a15d | |||
| e2a5f21f9d | |||
| 33b868dfac | |||
| 5c4ad1d8db | |||
| 37bc297d1a | |||
| bc1616c3f7 | |||
| 429eaabe6b | |||
| 607a0224cd | |||
| 456b3af461 | |||
| 1f7ecffe4c | |||
| 69f52921a7 | |||
| 9cb88dc181 | |||
| 7c9240fc84 | |||
| 2610288df0 | |||
| 9d49ed5e70 | |||
| bdbd135318 | |||
| d0ab838da6 | |||
| 50ab0f1748 | |||
| dc4295d1bd | |||
| 256526c5ec | |||
| bf501d2374 | |||
| 442500b1a1 | |||
| b40eb83f27 | |||
| f80a0f2c27 | |||
| f5e523a425 | |||
| b21403a47c | |||
| f9a5b6e81a | |||
| 1c787ed576 | |||
| aca3df4bf2 | |||
| fa0d928980 | |||
| 6f427cb553 | |||
| a7d55b7293 | |||
| f09e00e16f | |||
| aa0e140fec | |||
| f8d8e2d6d5 | |||
| 9859abba7e | |||
| c89560e4f8 | |||
| ffed560d43 | |||
| 5199b495e0 | |||
| 82b98aa4e3 | |||
| 7dffd40f3f | |||
| 0304201e80 | |||
| dd4362c397 | |||
| 87515decb3 | |||
| 28ef916a22 | |||
| 39e38aac7e | |||
| daf534b4db | |||
| 118993a01f | |||
| 8f361981bf | |||
| 4bb6ae20cd | |||
| ad757e9ba8 | |||
| e63b57db19 | |||
| fb4432b307 | |||
| ab44515e8b | |||
| 78365d648c | |||
| e9eddbb26e | |||
| c00ca3620d | |||
| 4d76ee6d0e | |||
| 2dbe665bdc | |||
| 1bc3bec977 | |||
| 81a6e3674b |
@@ -39,9 +39,6 @@ src/
|
|||||||
|
|
||||||
## UI 约束
|
## 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)
|
- C 端订单 **5 Tab**(含 pending_ship)
|
||||||
- 门店列表仅 `OPEN` 状态
|
- 门店列表仅 `OPEN` 状态
|
||||||
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
|
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
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`。
|
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
|
||||||
|
|
||||||
Vite 代理:`/api` → `localhost:3010`(可用 `VITE_API_TARGET` 覆盖)。
|
Vite 代理:`/api` → `localhost:3000`。
|
||||||
|
|
||||||
## 共享包
|
## 共享包
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
|
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
|
||||||
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
|
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
|
||||||
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
|
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
|
||||||
| ~~V2 / preV1~~ | 历史参考(**已压缩**;V2 全文见 git 2026-08-06 前) | **不再作为需求依据** |
|
| ~~V2 / preV1~~ | 历史参考 | **不再作为需求依据** |
|
||||||
|
|
||||||
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
|
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ pnpm db:generate && pnpm db:validate
|
|||||||
cd server/dukang-api && npx prisma db push && pnpm prisma:seed
|
cd server/dukang-api && npx prisma db push && pnpm prisma:seed
|
||||||
|
|
||||||
# 开发(分终端)
|
# 开发(分终端)
|
||||||
pnpm dev:api # http://localhost:3010/api/v1
|
pnpm dev:api # http://localhost:3000/api/v1
|
||||||
pnpm dev:user # :5173
|
pnpm dev:user # :5173
|
||||||
pnpm dev:shop # :5174
|
pnpm dev:shop # :5174
|
||||||
pnpm dev:partner # :5175
|
pnpm dev:partner # :5175
|
||||||
@@ -137,14 +137,11 @@ C 端门店仅 status=OPEN
|
|||||||
订单 Tab:待付款 | 已付款 | 已完成
|
订单 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 `:3010` | — |
|
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3000` | — |
|
||||||
| **staging 测试** | `dev` | `/opt/dukang-staging` | 8190–8194 | `*-test.dukanghaoke.com` |
|
| **staging 测试** | `dev` | `/opt/dukang-staging` | 8190–8194 | `*-test.dukanghaoke.com` |
|
||||||
| **production 生产** | `main` | `/opt/dukang` | 8090–8094 | `*.dukanghaoke.com` |
|
| **production 生产** | `main` | `/opt/dukang` | 8090–8094 | `*.dukanghaoke.com` |
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。
|
|||||||
- 禁止 import `server/` 或另一个 `apps/*` 的源码
|
- 禁止 import `server/` 或另一个 `apps/*` 的源码
|
||||||
- UI 共享组件优先 `@dukang/shared-ui`
|
- UI 共享组件优先 `@dukang/shared-ui`
|
||||||
- C 端订单列表 **3 Tab**:`待付款 | 已付款 | 已完成`(Tab key: `pending_pay` / `paid` / `completed`)
|
- 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
|
## 新页面 workflow
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.5.1",
|
"@ant-design/icons": "^5.5.1",
|
||||||
"@dukang/domain": "workspace:*",
|
|
||||||
"@dukang/shared-types": "workspace:*",
|
"@dukang/shared-types": "workspace:*",
|
||||||
"@dukang/shared-ui": "workspace:*",
|
"@dukang/shared-ui": "workspace:*",
|
||||||
"antd": "^5.22.0",
|
"antd": "^5.22.0",
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
|
|||||||
import WechatBindingsPage from './pages/WechatBindingsPage';
|
import WechatBindingsPage from './pages/WechatBindingsPage';
|
||||||
import HqPermissionsPage from './pages/HqPermissionsPage';
|
import HqPermissionsPage from './pages/HqPermissionsPage';
|
||||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||||
import TestWhitelistPage from './pages/TestWhitelistPage';
|
|
||||||
import WecomBotsPage from './pages/WecomBotsPage';
|
import WecomBotsPage from './pages/WecomBotsPage';
|
||||||
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
|
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
|
||||||
import WecomBotLogsPage from './pages/WecomBotLogsPage';
|
import WecomBotLogsPage from './pages/WecomBotLogsPage';
|
||||||
@@ -137,7 +136,6 @@ export default function App() {
|
|||||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||||
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
||||||
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
||||||
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
|
|
||||||
<Route path="/system-settings" element={<SystemSettingsPage />} />
|
<Route path="/system-settings" element={<SystemSettingsPage />} />
|
||||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -86,21 +86,3 @@ body,
|
|||||||
.admin-table-nowrap .ant-table-cell-ellipsis {
|
.admin-table-nowrap .ant-table-cell-ellipsis {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-package-audit-cols {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-package-audit-col {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-package-audit-text {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
overflow: visible;
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,47 +1,21 @@
|
|||||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
|
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
|
||||||
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||||
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT, normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import PackageImagesUpload from './PackageImagesUpload';
|
import OssUpload from './OssUpload';
|
||||||
|
|
||||||
type PackageRow = StorePackageItemDto;
|
type PackageRow = StorePackageItemDto;
|
||||||
|
|
||||||
export type AdminStorePackagesHandle = {
|
|
||||||
/** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */
|
|
||||||
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function emptyRow(index = 0): PackageRow {
|
function emptyRow(index = 0): PackageRow {
|
||||||
return {
|
return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', imageUrl: '', sortOrder: index };
|
||||||
name: '',
|
|
||||||
price: '0',
|
|
||||||
dishes: '',
|
|
||||||
usableTime: '',
|
|
||||||
otherNotes: '',
|
|
||||||
imageUrl: '',
|
|
||||||
imageUrls: [],
|
|
||||||
sortOrder: index,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId: string }>(
|
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
|
||||||
function AdminStorePackagesSection({ storeId }, ref) {
|
|
||||||
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
|
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
|
||||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const itemsRef = useRef(items);
|
|
||||||
const loadingRef = useRef(loading);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
itemsRef.current = items;
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadingRef.current = loading;
|
|
||||||
}, [loading]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -49,17 +23,8 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
setItems(
|
setItems(
|
||||||
data.live?.length
|
data.live?.length
|
||||||
? data.live.map((p, i) => {
|
? data.live.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i }))
|
||||||
const imageUrls = normalizeStorePackageImageUrls(p);
|
: [emptyRow()],
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
price: String(p.price),
|
|
||||||
imageUrl: imageUrls[0] ?? '',
|
|
||||||
imageUrls,
|
|
||||||
sortOrder: i,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
: [],
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
||||||
@@ -103,95 +68,56 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
}
|
}
|
||||||
run();
|
run();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCollapse(index: number) {
|
function toggleCollapse(index: number) {
|
||||||
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
|
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(opts?: { quiet?: boolean }) {
|
async function save() {
|
||||||
const currentItems = itemsRef.current;
|
const filled = items
|
||||||
const filled = currentItems
|
.map((item, index) => ({
|
||||||
.map((item, index) => {
|
|
||||||
const imageUrls = normalizeStorePackageImageUrls(item);
|
|
||||||
return {
|
|
||||||
name: item.name.trim(),
|
name: item.name.trim(),
|
||||||
price: item.price.trim(),
|
price: item.price.trim(),
|
||||||
dishes: item.dishes.trim(),
|
dishes: item.dishes.trim(),
|
||||||
usableTime: item.usableTime?.trim() || null,
|
usableTime: item.usableTime?.trim() || null,
|
||||||
otherNotes: item.otherNotes?.trim() || null,
|
otherNotes: item.otherNotes?.trim() || null,
|
||||||
imageUrl: imageUrls[0] ?? null,
|
imageUrl: item.imageUrl?.trim() || null,
|
||||||
imageUrls,
|
|
||||||
sortOrder: index,
|
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++) {
|
for (let i = 0; i < filled.length; i++) {
|
||||||
const item = filled[i];
|
const item = filled[i];
|
||||||
if (!item.name) {
|
if (!item.name) {
|
||||||
message.warning(`第 ${i + 1} 条套餐名称不能为空`);
|
message.warning(`第 ${i + 1} 条套餐名称不能为空`);
|
||||||
throw new Error('套餐校验失败');
|
return;
|
||||||
}
|
}
|
||||||
if (!item.dishes) {
|
if (!item.dishes) {
|
||||||
message.warning(`第 ${i + 1} 条套餐菜品不能为空`);
|
message.warning(`第 ${i + 1} 条套餐菜品不能为空`);
|
||||||
throw new Error('套餐校验失败');
|
return;
|
||||||
}
|
}
|
||||||
const price = Number(item.price);
|
const price = Number(item.price);
|
||||||
if (!Number.isFinite(price) || price < 0) {
|
if (!Number.isFinite(price) || price < 0) {
|
||||||
message.warning(`第 ${i + 1} 条套餐价格须为非负数字`);
|
message.warning(`第 ${i + 1} 条套餐价格须为非负数字`);
|
||||||
throw new Error('套餐校验失败');
|
return;
|
||||||
}
|
|
||||||
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
|
|
||||||
message.warning(`第 ${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张`);
|
|
||||||
throw new Error('套餐校验失败');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const data = await request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`, {
|
await request(`/admin/stores/${storeId}/packages`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
|
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!opts?.quiet) message.success('套餐已保存并生效');
|
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) {
|
} catch (e) {
|
||||||
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
throw e;
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
saveIfLoaded: async (opts) => {
|
|
||||||
if (loadingRef.current) return { skipped: true };
|
|
||||||
await save(opts);
|
|
||||||
return { skipped: false };
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
return <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
||||||
}
|
}
|
||||||
@@ -203,15 +129,14 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
||||||
添加套餐
|
添加套餐
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
|
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||||
保存套餐
|
保存套餐
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return ( <Form layout="vertical" requiredMark={false}>
|
||||||
<Form layout="vertical" requiredMark={false}>
|
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
@@ -248,8 +173,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
<Button type="link" danger onClick={() => removeAt(index)}>
|
<Button type="link" danger onClick={() => removeAt(index)}>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null} </Space>
|
||||||
</Space>
|
|
||||||
|
|
||||||
{!isCollapsed ? (
|
{!isCollapsed ? (
|
||||||
<>
|
<>
|
||||||
@@ -283,8 +207,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
|
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
|
||||||
<Input.TextArea
|
<Input
|
||||||
rows={2}
|
|
||||||
placeholder="节假日除外"
|
placeholder="节假日除外"
|
||||||
value={item.usableTime || ''}
|
value={item.usableTime || ''}
|
||||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||||
@@ -292,20 +215,15 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="套餐图片" style={{ marginBottom: 12 }}>
|
<Form.Item label="套餐图片" style={{ marginBottom: 12 }}>
|
||||||
<PackageImagesUpload
|
<OssUpload
|
||||||
value={normalizeStorePackageImageUrls(item)}
|
bizType="STORE_PACKAGE"
|
||||||
onChange={(imageUrls) =>
|
mediaType="IMAGE"
|
||||||
updateAt(index, {
|
value={item.imageUrl || undefined}
|
||||||
imageUrls,
|
onChange={(url) => updateAt(index, { imageUrl: url })}
|
||||||
imageUrl: imageUrls[0] ?? '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
|
<Form.Item label="其他说明" style={{ marginBottom: 0 }}> <Input
|
||||||
<Input.TextArea
|
|
||||||
rows={2}
|
|
||||||
placeholder="不可叠加"
|
placeholder="不可叠加"
|
||||||
value={item.otherNotes || ''}
|
value={item.otherNotes || ''}
|
||||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||||
@@ -324,15 +242,12 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
上传套餐图后请点右上角「保存修改」(会连同套餐一起保存),或点下方「保存套餐」。仅上传不保存,刷新会丢失。
|
修改后点击下方按钮保存,C 端将立即展示生效套餐。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
|
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||||
保存套餐
|
保存套餐
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
|
||||||
export default AdminStorePackagesSection;
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Typography } from 'antd';
|
import { Button, Form, Space, Typography } from 'antd';
|
||||||
import MultiImageUpload from './MultiImageUpload';
|
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import OssUpload from './OssUpload';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -7,35 +8,42 @@ type Props = {
|
|||||||
bizType?: string;
|
bizType?: string;
|
||||||
/** 最多可添加张数;不传则不限制 */
|
/** 最多可添加张数;不传则不限制 */
|
||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
/** Form.Item 注入 */
|
|
||||||
value?: string[];
|
|
||||||
onChange?: (urls: string[]) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 商品详情/轮播等多图列表。
|
|
||||||
* 可直接包在 Form.Item 下(value/onChange),也可用 Form.List 的 name 外层再包 Form.Item。
|
|
||||||
*/
|
|
||||||
export default function DetailImageUrlList({
|
export default function DetailImageUrlList({
|
||||||
|
name = 'detailImageUrls',
|
||||||
label,
|
label,
|
||||||
bizType = 'DETAIL',
|
bizType = 'DETAIL',
|
||||||
maxCount,
|
maxCount,
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{maxCount != null && (
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
{label}:支持批量上传{maxCount != null ? `,最多 ${maxCount} 张` : ''}
|
最多 {maxCount} 张{label}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<MultiImageUpload
|
)}
|
||||||
bizType={bizType}
|
<Form.List name={name}>
|
||||||
mediaType="IMAGE"
|
{(fields, { add, remove }) => (
|
||||||
maxCount={maxCount}
|
<>
|
||||||
value={value}
|
{fields.map((field) => (
|
||||||
onChange={onChange}
|
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
|
||||||
tip={`支持一次选择多张${label}`}
|
<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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { Button, Image, Space, Typography, Upload, message } from 'antd';
|
|
||||||
import { UploadOutlined, DeleteOutlined, FilePdfOutlined } 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;
|
|
||||||
/** 上传按钮文案,默认「批量上传图片」 */
|
|
||||||
buttonText?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeUrls(value?: string[]) {
|
|
||||||
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPdf(url: string) {
|
|
||||||
return /\.pdf(\?|$)/i.test(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 多图批量上传(一次可选多张),用于套餐图 / 环境照 / 商品详情图等。
|
|
||||||
* Form.Item 直接绑定 string[]。
|
|
||||||
*/
|
|
||||||
export default function MultiImageUpload({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
bizType,
|
|
||||||
mediaType = 'IMAGE',
|
|
||||||
maxCount,
|
|
||||||
tip,
|
|
||||||
accept = 'image/*',
|
|
||||||
buttonText = '批量上传图片',
|
|
||||||
}: 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 }}>
|
|
||||||
{isPdf(url) ? (
|
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
width: 96,
|
|
||||||
height: 96,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 4,
|
|
||||||
borderRadius: 6,
|
|
||||||
border: '1px solid #f0f0f0',
|
|
||||||
background: '#fafafa',
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FilePdfOutlined style={{ fontSize: 24, color: '#cf1322' }} />
|
|
||||||
<span>PDF</span>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<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 ? buttonText : '已达上限'}
|
|
||||||
</Button>
|
|
||||||
</Upload>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Image, Input, Modal, Space, Upload, message } from 'antd';
|
import { Button, Image, Input, Space, Upload, message } from 'antd';
|
||||||
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadProps } from 'antd';
|
import type { UploadProps } from 'antd';
|
||||||
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
||||||
|
|
||||||
@@ -15,14 +15,6 @@ type OssUploadProps = {
|
|||||||
placeholder?: string;
|
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({
|
export default function OssUpload({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -33,7 +25,6 @@ export default function OssUpload({
|
|||||||
placeholder = '上传后自动填入,或手动粘贴 URL',
|
placeholder = '上传后自动填入,或手动粘贴 URL',
|
||||||
}: OssUploadProps) {
|
}: OssUploadProps) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
|
|
||||||
|
|
||||||
const resolvedAccept =
|
const resolvedAccept =
|
||||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
||||||
@@ -56,57 +47,6 @@ 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 (
|
return (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
{value && mediaType === 'IMAGE' && (
|
{value && mediaType === 'IMAGE' && (
|
||||||
@@ -115,7 +55,6 @@ export default function OssUpload({
|
|||||||
{value && mediaType === 'VIDEO' && (
|
{value && mediaType === 'VIDEO' && (
|
||||||
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
||||||
)}
|
)}
|
||||||
{filePreview}
|
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Upload
|
<Upload
|
||||||
accept={resolvedAccept}
|
accept={resolvedAccept}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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} 张,支持批量选择上传,可逐张删除`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Layout, Menu, Typography, Button, Space, Badge } from 'antd';
|
import { Layout, Menu, Typography, Button, Space } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import {
|
import {
|
||||||
RobotOutlined,
|
RobotOutlined,
|
||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
|
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
|
||||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||||
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
@@ -147,7 +146,6 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||||
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
|
|
||||||
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||||
];
|
];
|
||||||
@@ -217,7 +215,6 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
|||||||
'/logs/third-party': 'logs',
|
'/logs/third-party': 'logs',
|
||||||
'/logs/domain-events': 'logs',
|
'/logs/domain-events': 'logs',
|
||||||
'/hq-permissions': 'hq_permissions',
|
'/hq-permissions': 'hq_permissions',
|
||||||
'/test-whitelist': 'test_whitelist',
|
|
||||||
'/system-settings': 'system_settings_any',
|
'/system-settings': 'system_settings_any',
|
||||||
'/hq-accounts': 'hq_accounts',
|
'/hq-accounts': 'hq_accounts',
|
||||||
};
|
};
|
||||||
@@ -244,31 +241,6 @@ function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): M
|
|||||||
.filter(Boolean) as MenuProps['items'];
|
.filter(Boolean) as MenuProps['items'];
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachPackageAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] {
|
|
||||||
if (!items) return items;
|
|
||||||
return items.map((item) => {
|
|
||||||
if (!item || typeof item !== 'object' || !('key' in item)) return item;
|
|
||||||
if ('children' in item && Array.isArray(item.children)) {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
children: attachPackageAuditBadge(item.children as MenuProps['items'], pendingCount),
|
|
||||||
} as MenuItem;
|
|
||||||
}
|
|
||||||
if (String(item.key) === '/store-package-audits') {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
label: (
|
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
套餐审核
|
|
||||||
{pendingCount > 0 && <Badge count={pendingCount} size="small" />}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
} as MenuItem;
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging';
|
const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging';
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
@@ -276,28 +248,11 @@ export default function AdminLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
const [packagePendingCount, setPackagePendingCount] = useState(0);
|
|
||||||
|
|
||||||
function refreshPackagePendingCount() {
|
|
||||||
request<{ pendingCount: number }>('/admin/store-package-audits/summary')
|
|
||||||
.then((data) => setPackagePendingCount(data.pendingCount ?? 0))
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refreshPackagePendingCount();
|
|
||||||
}, [location.pathname]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onChanged = () => refreshPackagePendingCount();
|
|
||||||
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
|
||||||
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
contentRef.current?.scrollTo({ top: 0, left: 0 });
|
contentRef.current?.scrollTo({ top: 0, left: 0 });
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
@@ -316,12 +271,10 @@ export default function AdminLayout() {
|
|||||||
: location.pathname;
|
: location.pathname;
|
||||||
|
|
||||||
const menuItems = useMemo(() => {
|
const menuItems = useMemo(() => {
|
||||||
const base =
|
if (!profile) return MENU_ITEMS;
|
||||||
!profile || profile.adminRole === 'SUPER_ADMIN'
|
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS;
|
||||||
? MENU_ITEMS
|
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
||||||
: filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
}, [profile]);
|
||||||
return attachPackageAuditBadge(base, packagePendingCount);
|
|
||||||
}, [profile, packagePendingCount]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export const PACKAGE_AUDIT_CHANGED_EVENT = 'admin:package-audit-changed';
|
|
||||||
|
|
||||||
export function notifyPackageAuditChanged() {
|
|
||||||
window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT));
|
|
||||||
}
|
|
||||||
@@ -167,13 +167,6 @@ export type AdminUserRow = {
|
|||||||
sourceLabel: string | null;
|
sourceLabel: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
isTest?: boolean;
|
|
||||||
/** 好客权益·累计获得(含已使用,不含退款作废) */
|
|
||||||
benefitTotalAmount?: number;
|
|
||||||
/** 好客权益·已使用(已核销) */
|
|
||||||
benefitUsedAmount?: number;
|
|
||||||
/** 好客权益·剩余未使用 */
|
|
||||||
benefitBalance?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminOrderItem = {
|
export type AdminOrderItem = {
|
||||||
@@ -206,7 +199,6 @@ export type AdminOrderRow = {
|
|||||||
isProxyOrder?: boolean;
|
isProxyOrder?: boolean;
|
||||||
proxyPartnerName?: string | null;
|
proxyPartnerName?: string | null;
|
||||||
proxyPartnerPhone?: string | null;
|
proxyPartnerPhone?: string | null;
|
||||||
isTest?: boolean;
|
|
||||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||||
delivery?: {
|
delivery?: {
|
||||||
provider: string;
|
provider: string;
|
||||||
|
|||||||
@@ -140,29 +140,10 @@ export function fmtTime(v?: string | null) {
|
|||||||
return v ? new Date(v).toLocaleString('zh-CN') : '—';
|
return v ? new Date(v).toLocaleString('zh-CN') : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 联系电话脱敏:手机 138****5678;座机 0379-****888;空值返回 — */
|
/** 手机号脱敏展示:138****5678;空值返回 — */
|
||||||
export function maskPhone(phone?: string | null): string {
|
export function maskPhone(phone?: string | null): string {
|
||||||
const raw = String(phone ?? '').trim().replace(/\s+/g, '');
|
const raw = String(phone ?? '').trim();
|
||||||
if (!raw) return '—';
|
if (!raw) return '—';
|
||||||
if (/^1[3-9]\d{9}$/.test(raw)) {
|
|
||||||
const digits = raw.replace(/\D/g, '');
|
|
||||||
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
|
||||||
}
|
|
||||||
if (/^0\d{2,3}-?\d{7,8}(-\d{1,6})?$/.test(raw)) {
|
|
||||||
const extMatch = raw.match(/-(\d{1,6})$/);
|
|
||||||
const hasExt = !!extMatch && raw.indexOf('-') !== raw.lastIndexOf('-');
|
|
||||||
const ext = hasExt ? extMatch![1] : '';
|
|
||||||
const main = hasExt ? raw.slice(0, -(ext.length + 1)) : raw;
|
|
||||||
const digits = main.replace(/\D/g, '');
|
|
||||||
const areaLen = digits.startsWith('01') || digits.startsWith('02') ? 3 : 4;
|
|
||||||
const area = digits.slice(0, areaLen);
|
|
||||||
const local = digits.slice(areaLen);
|
|
||||||
const keepTail = Math.min(4, Math.max(2, local.length - 4));
|
|
||||||
const maskedLocal =
|
|
||||||
local.length <= 4 ? '*'.repeat(local.length) : `${'*'.repeat(local.length - keepTail)}${local.slice(-keepTail)}`;
|
|
||||||
const joiner = main.includes('-') ? '-' : '';
|
|
||||||
return ext ? `${area}${joiner}${maskedLocal}-${ext}` : `${area}${joiner}${maskedLocal}`;
|
|
||||||
}
|
|
||||||
const digits = raw.replace(/\D/g, '');
|
const digits = raw.replace(/\D/g, '');
|
||||||
if (digits.length >= 11) {
|
if (digits.length >= 11) {
|
||||||
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
|
|
||||||
|
|
||||||
export type StoreCreateForm = {
|
export type StoreCreateForm = {
|
||||||
partnerAccountId: string;
|
partnerAccountId: string;
|
||||||
cityId: string;
|
cityId: string;
|
||||||
@@ -17,8 +15,6 @@ export type StoreCreateForm = {
|
|||||||
longitude?: number | null;
|
longitude?: number | null;
|
||||||
intro?: string;
|
intro?: string;
|
||||||
benefitUsageRule?: string;
|
benefitUsageRule?: string;
|
||||||
/** 对外联系电话(店长);可与登录号不同,支持座机 */
|
|
||||||
contactPhone?: string;
|
|
||||||
openTime?: string;
|
openTime?: string;
|
||||||
closeTime?: string;
|
closeTime?: string;
|
||||||
openTime2?: string;
|
openTime2?: string;
|
||||||
@@ -26,13 +22,11 @@ export type StoreCreateForm = {
|
|||||||
avgPrice?: number | null;
|
avgPrice?: number | null;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
envPhotoUrls?: string[];
|
envPhotoUrls?: string[];
|
||||||
/** 签约合同,支持多张照片 / PDF */
|
contractUrl?: string;
|
||||||
contractUrls?: string[];
|
|
||||||
bankAccountName: string;
|
bankAccountName: string;
|
||||||
bankAccountNo: string;
|
bankAccountNo: string;
|
||||||
bankBranch: string;
|
bankBranch: string;
|
||||||
settlementRate?: number;
|
settlementRate?: number;
|
||||||
sortOrder?: number;
|
|
||||||
visibilityWhitelistEnabled?: boolean;
|
visibilityWhitelistEnabled?: boolean;
|
||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
};
|
};
|
||||||
@@ -63,7 +57,6 @@ export function validateStoreCreateStep1(
|
|||||||
| 'openTime2'
|
| 'openTime2'
|
||||||
| 'closeTime2'
|
| 'closeTime2'
|
||||||
| 'avgPrice'
|
| 'avgPrice'
|
||||||
| 'contactPhone'
|
|
||||||
>,
|
>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.partnerAccountId) return '请选择开城合伙人';
|
if (!form.partnerAccountId) return '请选择开城合伙人';
|
||||||
@@ -73,8 +66,6 @@ export function validateStoreCreateStep1(
|
|||||||
if (!form.name?.trim()) return '请填写门店名称';
|
if (!form.name?.trim()) return '请填写门店名称';
|
||||||
if (!form.phone?.trim()) return '请填写门店手机号';
|
if (!form.phone?.trim()) return '请填写门店手机号';
|
||||||
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
||||||
const contactPhone = form.contactPhone?.trim();
|
|
||||||
if (contactPhone && !isStoreContactPhone(contactPhone)) return STORE_CONTACT_PHONE_HINT;
|
|
||||||
if (!form.address?.trim()) return '请填写详细地址';
|
if (!form.address?.trim()) return '请填写详细地址';
|
||||||
|
|
||||||
const openTime = String(form.openTime || '').trim();
|
const openTime = String(form.openTime || '').trim();
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ type Row = {
|
|||||||
accountCount: number;
|
accountCount: number;
|
||||||
subAccounts?: SubRow[];
|
subAccounts?: SubRow[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isTest?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PartnerDetail = Row & {
|
type PartnerDetail = Row & {
|
||||||
@@ -125,15 +124,14 @@ export default function CityPartnersPage() {
|
|||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [subForm] = Form.useForm();
|
const [subForm] = Form.useForm();
|
||||||
const [subEditForm] = Form.useForm();
|
const [subEditForm] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/partners',
|
'/admin/partners',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.companyName) qs.set('companyName', String(filters.companyName));
|
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||||
if (filters.phone) qs.set('phone', String(filters.phone));
|
if (filters.phone) qs.set('phone', filters.phone);
|
||||||
if (filters.cityId) qs.set('cityId', String(filters.cityId));
|
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -272,18 +270,7 @@ export default function CityPartnersPage() {
|
|||||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||||
},
|
},
|
||||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
|
{ 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: '登录手机', dataIndex: 'phone', width: 120 },
|
||||||
{
|
{
|
||||||
title: '管辖',
|
title: '管辖',
|
||||||
@@ -400,9 +387,6 @@ export default function CityPartnersPage() {
|
|||||||
<Form.Item name="phone" label="手机">
|
<Form.Item name="phone" label="手机">
|
||||||
<Input allowClear placeholder="登录手机" />
|
<Input allowClear placeholder="登录手机" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="excludeTest" valuePropName="checked">
|
|
||||||
<Checkbox>过滤测试账号</Checkbox>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
|
|||||||
@@ -3,26 +3,13 @@ import {
|
|||||||
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string; provider: string; trackingNo: string | null; providerOrderNo: string | null; updatedAt: string;
|
||||||
orderId: string;
|
order?: { orderNo: string; status: string; receiverName: string; receiverPhone: string; deliveryType: 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() {
|
export default function DeliveriesPage() {
|
||||||
@@ -42,22 +29,6 @@ export default function DeliveriesPage() {
|
|||||||
);
|
);
|
||||||
const [detail, setDetail] = useState<Row | null>(null);
|
const [detail, setDetail] = useState<Row | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
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> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
|
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
|
||||||
@@ -68,17 +39,14 @@ export default function DeliveriesPage() {
|
|||||||
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
||||||
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作', width: 140,
|
title: '操作', width: 80,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0}>
|
|
||||||
<Button type="link" size="small" onClick={() => openTrack(row)}>路由</Button>
|
|
||||||
<Button type="link" size="small" onClick={async () => {
|
<Button type="link" size="small" onClick={async () => {
|
||||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||||
setDetail(d);
|
setDetail(d);
|
||||||
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}}>编辑</Button>
|
}}>编辑</Button>
|
||||||
</Space>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -119,22 +87,9 @@ export default function DeliveriesPage() {
|
|||||||
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
||||||
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Button
|
|
||||||
style={{ marginTop: 8 }}
|
|
||||||
onClick={() => openTrack(detail)}
|
|
||||||
>
|
|
||||||
查看路由
|
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
<OrderTrackDrawer
|
|
||||||
open={trackOpen}
|
|
||||||
orderId={trackOrderId}
|
|
||||||
orderNo={trackOrderNo}
|
|
||||||
orderStatus={trackOrderStatus}
|
|
||||||
onClose={() => setTrackOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Link } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
|
||||||
Collapse,
|
Collapse,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -31,7 +30,6 @@ import {
|
|||||||
} from '../lib/constants';
|
} from '../lib/constants';
|
||||||
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
||||||
import ProxyOrderModal from '../components/ProxyOrderModal';
|
import ProxyOrderModal from '../components/ProxyOrderModal';
|
||||||
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
|
||||||
|
|
||||||
type ShipDefaults = {
|
type ShipDefaults = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -188,7 +186,6 @@ export default function OrdersPage() {
|
|||||||
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
|
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
|
||||||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||||
const [proxyOpen, setProxyOpen] = useState(false);
|
const [proxyOpen, setProxyOpen] = useState(false);
|
||||||
const [trackOpen, setTrackOpen] = useState(false);
|
|
||||||
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
||||||
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
||||||
|
|
||||||
@@ -226,7 +223,6 @@ export default function OrdersPage() {
|
|||||||
if (values.cityId) qs.set('cityId', values.cityId);
|
if (values.cityId) qs.set('cityId', values.cityId);
|
||||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||||
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
||||||
if (values.excludeTest) qs.set('excludeTest', 'true');
|
|
||||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||||
setData(res);
|
setData(res);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -434,17 +430,7 @@ export default function OrdersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<AdminOrderRow> = [
|
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: '城市',
|
title: '城市',
|
||||||
width: 90,
|
width: 90,
|
||||||
@@ -612,9 +598,6 @@ export default function OrdersPage() {
|
|||||||
options={[{ value: true, label: '仅待确认大单' }]}
|
options={[{ value: true, label: '仅待确认大单' }]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="excludeTest" valuePropName="checked">
|
|
||||||
<Checkbox>过滤测试账号</Checkbox>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
@@ -876,20 +859,7 @@ export default function OrdersPage() {
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
{detail.delivery && (
|
{detail.delivery && (
|
||||||
<Descriptions
|
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
|
||||||
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="快递公司">
|
<Descriptions.Item label="快递公司">
|
||||||
{detail.delivery.logisticsCompany ||
|
{detail.delivery.logisticsCompany ||
|
||||||
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
|
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
|
||||||
@@ -1262,14 +1232,6 @@ export default function OrdersPage() {
|
|||||||
void openDetail(order.id);
|
void openDetail(order.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OrderTrackDrawer
|
|
||||||
open={trackOpen}
|
|
||||||
orderId={detail?.id ?? null}
|
|
||||||
orderNo={detail?.orderNo}
|
|
||||||
orderStatus={detail?.status}
|
|
||||||
onClose={() => setTrackOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function mapToForm(d: Record<string, unknown>) {
|
|||||||
const row = d as Row;
|
const row = d as Row;
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [],
|
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [''],
|
||||||
features: row.features?.length
|
features: row.features?.length
|
||||||
? row.features
|
? row.features
|
||||||
: [{ icon: 'star', title: '', desc: '' }],
|
: [{ icon: 'star', title: '', desc: '' }],
|
||||||
@@ -68,14 +68,12 @@ function buildPayload(v: TemplateFormValues) {
|
|||||||
function TemplateContentFields() {
|
function TemplateContentFields() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography.Text type="secondary">模板详情长图(套用商品时可逐张修改,支持批量上传)</Typography.Text>
|
<Typography.Text type="secondary">模板详情长图(套用商品时可逐张修改)</Typography.Text>
|
||||||
<Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
|
|
||||||
<DetailImageUrlList
|
<DetailImageUrlList
|
||||||
label="详情图"
|
label="详情图"
|
||||||
bizType="DETAIL_TEMPLATE"
|
bizType="DETAIL_TEMPLATE"
|
||||||
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
|
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<Form.Item name="storyTitle" label="故事标题">
|
<Form.Item name="storyTitle" label="故事标题">
|
||||||
<Input placeholder="如:千年杜康 · 唯有此处" />
|
<Input placeholder="如:千年杜康 · 唯有此处" />
|
||||||
@@ -230,7 +228,7 @@ export default function ProductDetailTemplatesPage() {
|
|||||||
}} width={640}>
|
}} width={640}>
|
||||||
<Form form={createForm} layout="vertical" initialValues={{
|
<Form form={createForm} layout="vertical" initialValues={{
|
||||||
status: 'ACTIVE', sortOrder: 0,
|
status: 'ACTIVE', sortOrder: 0,
|
||||||
detailImageUrls: [],
|
detailImageUrls: [''],
|
||||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||||
}}>
|
}}>
|
||||||
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
|
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
||||||
Switch, Table, Tabs, Tag, Typography, message,
|
Switch, Table, Tabs, Tag, Typography, message,
|
||||||
@@ -9,7 +9,6 @@ import { request } from '../lib/api';
|
|||||||
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
import MultiImageUpload from '../components/MultiImageUpload';
|
|
||||||
import DetailImageUrlList from '../components/DetailImageUrlList';
|
import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||||
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
||||||
import type { FormInstance } from 'antd/es/form';
|
import type { FormInstance } from 'antd/es/form';
|
||||||
@@ -68,14 +67,21 @@ type ProductFormValues = {
|
|||||||
features?: Array<{ icon?: string; title?: string; desc?: string }>;
|
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>) {
|
function mapDetailToForm(d: Record<string, unknown>) {
|
||||||
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
|
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
|
||||||
const row = d as Row;
|
const row = d as Row;
|
||||||
return {
|
return {
|
||||||
...d,
|
...d,
|
||||||
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
||||||
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : []) as string[],
|
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : ['']) as string[],
|
||||||
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : []) as string[],
|
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : ['']) as string[],
|
||||||
allowOnlinePurchase: row.allowOnlinePurchase !== false,
|
allowOnlinePurchase: row.allowOnlinePurchase !== false,
|
||||||
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
|
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
|
||||||
allowOnSitePickup: !!row.allowOnSitePickup,
|
allowOnSitePickup: !!row.allowOnSitePickup,
|
||||||
@@ -106,6 +112,10 @@ function buildProductPayload(v: ProductFormValues) {
|
|||||||
features: features.length ? features : undefined,
|
features: features.length ? features : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const visibilityPhones = (v.visibilityPhones ?? [])
|
||||||
|
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
barcode69: v.barcode69,
|
barcode69: v.barcode69,
|
||||||
name: v.name,
|
name: v.name,
|
||||||
@@ -121,6 +131,7 @@ function buildProductPayload(v: ProductFormValues) {
|
|||||||
allowCrossCityDelivery:
|
allowCrossCityDelivery:
|
||||||
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
|
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
|
||||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||||
|
visibilityPhones,
|
||||||
coverUrl: v.coverUrl,
|
coverUrl: v.coverUrl,
|
||||||
carouselUrls,
|
carouselUrls,
|
||||||
detailImageUrls,
|
detailImageUrls,
|
||||||
@@ -130,13 +141,25 @@ function buildProductPayload(v: ProductFormValues) {
|
|||||||
|
|
||||||
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
|
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
|
||||||
return (
|
return (
|
||||||
<Form.Item name={name} style={{ marginBottom: 0 }}>
|
<Form.List name={name}>
|
||||||
<MultiImageUpload
|
{(fields, { add, remove }) => (
|
||||||
bizType={bizType}
|
<>
|
||||||
mediaType="IMAGE"
|
{fields.map((field) => (
|
||||||
tip={`${label}支持一次选择多张批量上传`}
|
<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>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,13 +168,11 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
|
|||||||
<>
|
<>
|
||||||
<ProductDetailTemplatePicker form={form} aromaType={aromaType} />
|
<ProductDetailTemplatePicker form={form} aromaType={aromaType} />
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography.Text type="secondary">详情页轮播(CAROUSEL,支持批量上传,单张最大 10MB)</Typography.Text>
|
<Typography.Text type="secondary">详情页轮播(CAROUSEL,单张最大 10MB)</Typography.Text>
|
||||||
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
|
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
|
||||||
<Divider />
|
<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" />
|
<DetailImageUrlList label="详情图" bizType="DETAIL" />
|
||||||
</Form.Item>
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<Form.Item name="storyTitle" label="故事标题">
|
<Form.Item name="storyTitle" label="故事标题">
|
||||||
<Input placeholder="如:千年杜康 · 唯有此处" />
|
<Input placeholder="如:千年杜康 · 唯有此处" />
|
||||||
@@ -192,6 +213,33 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
|
|||||||
}
|
}
|
||||||
|
|
||||||
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
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);
|
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -200,14 +248,46 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
|||||||
name="visibilityWhitelistEnabled"
|
name="visibilityWhitelistEnabled"
|
||||||
label="可见白名单"
|
label="可见白名单"
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
extra="开启后仅全局测试白名单内手机号在 C 端可见/可购,用于在线测试"
|
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试"
|
||||||
>
|
>
|
||||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{enabled ? (
|
{enabled ? (
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
<>
|
||||||
可见手机号见白名单管理
|
<Form.Item
|
||||||
</Typography.Text>
|
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>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -339,8 +419,8 @@ export default function ProductsPage() {
|
|||||||
title: '白名单',
|
title: '白名单',
|
||||||
dataIndex: 'visibilityWhitelistEnabled',
|
dataIndex: 'visibilityWhitelistEnabled',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v: boolean) =>
|
render: (v: boolean, row) =>
|
||||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '履约',
|
title: '履约',
|
||||||
@@ -459,7 +539,7 @@ export default function ProductsPage() {
|
|||||||
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
|
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
|
||||||
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
|
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
|
||||||
visibilityWhitelistEnabled: false, visibilityPhones: [],
|
visibilityWhitelistEnabled: false, visibilityPhones: [],
|
||||||
carouselUrls: [], detailImageUrls: [],
|
carouselUrls: [''], detailImageUrls: [''],
|
||||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||||
}}>
|
}}>
|
||||||
<Tabs items={[
|
<Tabs items={[
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
import { Button, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||||
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||||
@@ -14,7 +14,6 @@ type Row = {
|
|||||||
settleAmount: number;
|
settleAmount: number;
|
||||||
channel?: RedeemChannel;
|
channel?: RedeemChannel;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isTest?: boolean;
|
|
||||||
user?: { userNo: string; phone: string | null; nickname?: string | null };
|
user?: { userNo: string; phone: string | null; nickname?: string | null };
|
||||||
store?: { name: string; cityName: string };
|
store?: { name: string; cityName: string };
|
||||||
coupon?: { couponNo: string };
|
coupon?: { couponNo: string };
|
||||||
@@ -27,15 +26,14 @@ function maskPhone(phone: string | null | undefined) {
|
|||||||
|
|
||||||
export default function RedeemRecordsPage() {
|
export default function RedeemRecordsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||||
'/admin/redeem-records',
|
'/admin/redeem-records',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo));
|
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
||||||
if (filters.storeId) qs.set('storeId', String(filters.storeId));
|
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||||
if (filters.channel) qs.set('channel', String(filters.channel));
|
if (filters.channel) qs.set('channel', filters.channel);
|
||||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -55,21 +53,7 @@ export default function RedeemRecordsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
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: '方式',
|
title: '方式',
|
||||||
dataIndex: 'channel',
|
dataIndex: 'channel',
|
||||||
@@ -140,9 +124,6 @@ export default function RedeemRecordsPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="excludeTest" valuePropName="checked">
|
|
||||||
<Checkbox>过滤测试账号</Checkbox>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
查询
|
查询
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
@@ -15,7 +15,6 @@ type Row = {
|
|||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isTest?: boolean;
|
|
||||||
storeCount?: number;
|
storeCount?: number;
|
||||||
staffCount?: number;
|
staffCount?: number;
|
||||||
bankAccountName?: string | null;
|
bankAccountName?: string | null;
|
||||||
@@ -31,14 +30,13 @@ type StoreOption = { id: string; name: string };
|
|||||||
export default function StoreAccountsPage() {
|
export default function StoreAccountsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/store-accounts',
|
'/admin/store-accounts',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.phone) qs.set('phone', String(filters.phone));
|
if (filters.phone) qs.set('phone', filters.phone);
|
||||||
if (filters.status) qs.set('status', String(filters.status));
|
if (filters.status) qs.set('status', filters.status);
|
||||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -75,17 +73,7 @@ export default function StoreAccountsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
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: '手机', dataIndex: 'phone', width: 120 },
|
||||||
{
|
{
|
||||||
title: '绑定门店',
|
title: '绑定门店',
|
||||||
@@ -174,9 +162,6 @@ export default function StoreAccountsPage() {
|
|||||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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 type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Badge,
|
|
||||||
Button,
|
Button,
|
||||||
Drawer,
|
Drawer,
|
||||||
Image,
|
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Space,
|
Space,
|
||||||
@@ -15,32 +13,19 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type {
|
import type {
|
||||||
StorePackageAuditDetailDto,
|
StorePackageAuditDetailDto,
|
||||||
StorePackageAuditSummaryDto,
|
|
||||||
StorePackageChangeRequestDto,
|
StorePackageChangeRequestDto,
|
||||||
StorePackageChangeStatus,
|
|
||||||
StorePackageItemDto,
|
StorePackageItemDto,
|
||||||
StorePackageViewDto,
|
StorePackageViewDto,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
import { STORE_PACKAGE_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
|
||||||
PENDING: '待审核',
|
|
||||||
APPROVED: '已通过',
|
|
||||||
REJECTED: '已驳回',
|
|
||||||
};
|
|
||||||
|
|
||||||
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
||||||
const name = String(pkg.name ?? '').trim();
|
const name = String(pkg.name ?? '').trim();
|
||||||
return name ? `name:${name}` : `idx:${index}`;
|
return name ? `name:${name}` : `idx:${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
|
|
||||||
return normalizeStorePackageImageUrls(pkg).join('|');
|
|
||||||
}
|
|
||||||
|
|
||||||
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
||||||
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
||||||
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
||||||
@@ -64,8 +49,7 @@ function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto
|
|||||||
l.price !== p.price ||
|
l.price !== p.price ||
|
||||||
l.dishes !== p.dishes ||
|
l.dishes !== p.dishes ||
|
||||||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
(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 });
|
rows.push({ key, change: changed ? 'changed' : 'unchanged', live: l, proposed: p });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,86 +63,12 @@ const CHANGE_LABELS = {
|
|||||||
unchanged: { text: '未变', color: 'default' },
|
unchanged: { text: '未变', color: 'default' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function PackageDetailCard({
|
|
||||||
title,
|
|
||||||
pkg,
|
|
||||||
change,
|
|
||||||
}: {
|
|
||||||
title?: string;
|
|
||||||
pkg: StorePackageItemDto | StorePackageViewDto;
|
|
||||||
change?: keyof typeof CHANGE_LABELS;
|
|
||||||
}) {
|
|
||||||
const images = normalizeStorePackageImageUrls(pkg);
|
|
||||||
const meta = change ? CHANGE_LABELS[change] : null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: 12,
|
|
||||||
padding: 12,
|
|
||||||
border: '1px solid #f0f0f0',
|
|
||||||
borderRadius: 8,
|
|
||||||
background: '#fafafa',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Space style={{ marginBottom: 8 }} wrap>
|
|
||||||
{title ? (
|
|
||||||
<Typography.Text type="secondary">{title}</Typography.Text>
|
|
||||||
) : null}
|
|
||||||
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
|
|
||||||
</Space>
|
|
||||||
<div style={{ marginBottom: 8 }}>
|
|
||||||
<strong>{pkg.name}</strong>
|
|
||||||
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
|
|
||||||
</div>
|
|
||||||
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
|
||||||
{pkg.dishes || '—'}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
{pkg.usableTime ? (
|
|
||||||
<Typography.Paragraph
|
|
||||||
type="secondary"
|
|
||||||
className="admin-package-audit-text"
|
|
||||||
style={{ marginBottom: 4 }}
|
|
||||||
>
|
|
||||||
可用时间:{pkg.usableTime}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
) : null}
|
|
||||||
{pkg.otherNotes ? (
|
|
||||||
<Typography.Paragraph
|
|
||||||
type="secondary"
|
|
||||||
className="admin-package-audit-text"
|
|
||||||
style={{ marginBottom: 8 }}
|
|
||||||
>
|
|
||||||
其他说明:{pkg.otherNotes}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
) : 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StorePackageAuditsPage() {
|
export default function StorePackageAuditsPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [status, setStatus] = useState<string>('PENDING');
|
const [status, setStatus] = useState<string>('PENDING');
|
||||||
const [pendingCount, setPendingCount] = useState(0);
|
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
@@ -174,14 +84,12 @@ export default function StorePackageAuditsPage() {
|
|||||||
pageSize: '20',
|
pageSize: '20',
|
||||||
});
|
});
|
||||||
if (nextStatus) qs.set('status', nextStatus);
|
if (nextStatus) qs.set('status', nextStatus);
|
||||||
const [data, summary] = await Promise.all([
|
const data = await request<Paginated<StorePackageChangeRequestDto>>(
|
||||||
request<Paginated<StorePackageChangeRequestDto>>(`/admin/store-package-audits?${qs}`),
|
`/admin/store-package-audits?${qs}`,
|
||||||
request<StorePackageAuditSummaryDto>('/admin/store-package-audits/summary'),
|
);
|
||||||
]);
|
|
||||||
setItems(data.items);
|
setItems(data.items);
|
||||||
setTotal(data.total);
|
setTotal(data.total);
|
||||||
setPage(data.page);
|
setPage(data.page);
|
||||||
setPendingCount(summary.pendingCount ?? 0);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '加载失败');
|
message.error(e instanceof Error ? e.message : '加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -218,7 +126,6 @@ export default function StorePackageAuditsPage() {
|
|||||||
});
|
});
|
||||||
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
|
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
|
||||||
setDetailOpen(false);
|
setDetailOpen(false);
|
||||||
notifyPackageAuditChanged();
|
|
||||||
void reload(page, status);
|
void reload(page, status);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '操作失败');
|
message.error(e instanceof Error ? e.message : '操作失败');
|
||||||
@@ -226,7 +133,54 @@ export default function StorePackageAuditsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
|
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
|
||||||
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
|
|
||||||
|
const diffColumns: ColumnsType<(typeof diffRows)[number]> = [
|
||||||
|
{
|
||||||
|
title: '变更',
|
||||||
|
dataIndex: 'change',
|
||||||
|
width: 72,
|
||||||
|
render: (v: keyof typeof CHANGE_LABELS) => {
|
||||||
|
const meta = CHANGE_LABELS[v];
|
||||||
|
return <Tag color={meta.color}>{meta.text}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||||
@@ -234,7 +188,7 @@ export default function StorePackageAuditsPage() {
|
|||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
render: (v: StorePackageChangeRequestDto['status']) => (
|
render: (v: StorePackageChangeRequestDto['status']) => (
|
||||||
<Tag>{HQ_PACKAGE_STATUS_LABELS[v] ?? v}</Tag>
|
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -284,15 +238,7 @@ export default function StorePackageAuditsPage() {
|
|||||||
<Space style={{ marginBottom: 16 }}>
|
<Space style={{ marginBottom: 16 }}>
|
||||||
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
||||||
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
|
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
|
||||||
{s === 'PENDING' ? (
|
{s ? STORE_PACKAGE_CHANGE_STATUS_LABELS[s as keyof typeof STORE_PACKAGE_CHANGE_STATUS_LABELS] : '全部'}
|
||||||
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
|
||||||
{HQ_PACKAGE_STATUS_LABELS.PENDING}
|
|
||||||
</Badge>
|
|
||||||
) : s ? (
|
|
||||||
HQ_PACKAGE_STATUS_LABELS[s]
|
|
||||||
) : (
|
|
||||||
'全部'
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -311,7 +257,7 @@ export default function StorePackageAuditsPage() {
|
|||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
|
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
|
||||||
width={880}
|
width={720}
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
onClose={() => setDetailOpen(false)}
|
onClose={() => setDetailOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
@@ -336,8 +282,8 @@ export default function StorePackageAuditsPage() {
|
|||||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||||
) : detail ? (
|
) : detail ? (
|
||||||
<>
|
<>
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
<Space style={{ marginBottom: 16 }}>
|
||||||
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status]}</Tag>
|
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]}</Tag>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
@@ -345,45 +291,16 @@ export default function StorePackageAuditsPage() {
|
|||||||
{detail.rejectReason ? (
|
{detail.rejectReason ? (
|
||||||
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||||||
) : null}
|
) : null}
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
<Typography.Paragraph type="secondary">
|
||||||
线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条
|
线上 {detail.livePackages?.length ?? 0} 条 → 申请 {detail.packages?.length ?? 0} 条
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<div className="admin-package-audit-cols">
|
<Table
|
||||||
<div className="admin-package-audit-col">
|
size="small"
|
||||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
rowKey="key"
|
||||||
线上已审核套餐
|
columns={diffColumns}
|
||||||
</Typography.Title>
|
dataSource={diffRows}
|
||||||
{(detail.livePackages ?? []).length ? (
|
pagination={false}
|
||||||
(detail.livePackages ?? []).map((pkg, index) => (
|
|
||||||
<PackageDetailCard
|
|
||||||
key={`live-${packageKey(pkg, index)}`}
|
|
||||||
title={`套餐 ${index + 1}`}
|
|
||||||
pkg={pkg}
|
|
||||||
change={changeByKey.get(packageKey(pkg, index))}
|
|
||||||
/>
|
/>
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">暂无线上套餐</Typography.Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="admin-package-audit-col">
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
|
||||||
待审核套餐
|
|
||||||
</Typography.Title>
|
|
||||||
{(detail.packages ?? []).length ? (
|
|
||||||
(detail.packages ?? []).map((pkg, index) => (
|
|
||||||
<PackageDetailCard
|
|
||||||
key={`pending-${packageKey(pkg, index)}`}
|
|
||||||
title={`套餐 ${index + 1}`}
|
|
||||||
pkg={pkg}
|
|
||||||
change={changeByKey.get(packageKey(pkg, index))}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">暂无待审核套餐</Typography.Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
@@ -23,11 +22,11 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { FormInstance } from 'antd/es/form';
|
import type { FormInstance } from 'antd/es/form';
|
||||||
import { EnvironmentOutlined } from '@ant-design/icons';
|
import { FilePdfOutlined, LinkOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||||
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
|
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import {
|
import {
|
||||||
ADMIN_OPTIONS_PAGE_SIZE,
|
ADMIN_OPTIONS_PAGE_SIZE,
|
||||||
|
RESOURCE_BIZ_TYPE_LABELS,
|
||||||
STORE_AUDIT_STATUS_LABELS,
|
STORE_AUDIT_STATUS_LABELS,
|
||||||
STORE_STATUS_LABELS,
|
STORE_STATUS_LABELS,
|
||||||
fmtTime,
|
fmtTime,
|
||||||
@@ -41,11 +40,8 @@ import { useAdminList } from '../lib/useAdminList';
|
|||||||
import { resolveRegionBinding } from '../lib/china-region';
|
import { resolveRegionBinding } from '../lib/china-region';
|
||||||
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
import MultiImageUpload from '../components/MultiImageUpload';
|
|
||||||
import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
||||||
import AdminStorePackagesSection, {
|
import AdminStorePackagesSection from '../components/AdminStorePackagesSection';
|
||||||
type AdminStorePackagesHandle,
|
|
||||||
} from '../components/AdminStorePackagesSection';
|
|
||||||
|
|
||||||
const CREATE_STEPS = [
|
const CREATE_STEPS = [
|
||||||
{ title: '基本信息' },
|
{ title: '基本信息' },
|
||||||
@@ -85,6 +81,18 @@ type StoreMediaItem = {
|
|||||||
url?: string | null;
|
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>) {
|
function collectMediaUrls(detail: Record<string, unknown>) {
|
||||||
const media = Array.isArray(detail.media) ? (detail.media as StoreMediaItem[]) : [];
|
const media = Array.isArray(detail.media) ? (detail.media as StoreMediaItem[]) : [];
|
||||||
const byType = (bizType: string) =>
|
const byType = (bizType: string) =>
|
||||||
@@ -110,50 +118,195 @@ function collectMediaUrls(detail: Record<string, unknown>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function StoreAuditMediaEditor() {
|
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) {
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照、签约合同均最多 20 张。套餐请在「套餐」页签编辑,同样由「保存修改」一并提交。"
|
message="暂无门头照 / 环境照 / 签约合同,请谨慎审核"
|
||||||
/>
|
/>
|
||||||
<Form.Item name="coverUrl" label="门头照">
|
);
|
||||||
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
}
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
return (
|
||||||
name="envPhotoUrls"
|
<div style={{ marginBottom: 16 }}>
|
||||||
label="环境照片"
|
<Typography.Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
|
||||||
extra="建议至少 3 张;支持批量上传,最多 20 张。可逐张删除后保存。"
|
审核材料
|
||||||
|
</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' }}
|
||||||
|
/>
|
||||||
|
<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',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MultiImageUpload
|
{imageLike ? (
|
||||||
bizType="STORE_ENV"
|
<Image.PreviewGroup>
|
||||||
mediaType="IMAGE"
|
<Image
|
||||||
maxCount={20}
|
src={item.url}
|
||||||
tip="环境照支持一次选择多张批量上传"
|
width={96}
|
||||||
|
height={72}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Image.PreviewGroup>
|
||||||
<Form.Item
|
) : (
|
||||||
name="contractUrls"
|
<div
|
||||||
label="签约合同"
|
style={{
|
||||||
style={{ marginTop: 16 }}
|
width: 96,
|
||||||
extra="支持多张合同照片(如首页、盖章页),也可上传 PDF,最多 20 个。"
|
height: 72,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px dashed #d9d9d9',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#cf1322',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MultiImageUpload
|
<FilePdfOutlined style={{ fontSize: 28 }} />
|
||||||
bizType="STORE_CONTRACT"
|
</div>
|
||||||
mediaType="FILE"
|
)}
|
||||||
accept="image/*,.pdf"
|
<Space direction="vertical" size={4} style={{ flex: 1, minWidth: 0 }}>
|
||||||
maxCount={20}
|
<Typography.Text strong>
|
||||||
buttonText="批量上传合同"
|
{RESOURCE_BIZ_TYPE_LABELS.CONTRACT || '合同'}
|
||||||
tip="合同支持一次选择多张照片批量上传,最多 20 个"
|
{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 }}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
) : null}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserPickRow = { id: string; phone?: string | null; nickname?: string | null; userNo?: string | null };
|
||||||
|
|
||||||
function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
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);
|
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -162,14 +315,46 @@ function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
|||||||
name="visibilityWhitelistEnabled"
|
name="visibilityWhitelistEnabled"
|
||||||
label="可见白名单"
|
label="可见白名单"
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
extra="开启后仅全局测试白名单内手机号在 C 端可见,用于在线测试"
|
extra="开启后仅名单内手机号在 C 端可见,用于在线测试"
|
||||||
>
|
>
|
||||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{enabled ? (
|
{enabled ? (
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
<>
|
||||||
可见手机号见白名单管理
|
<Form.Item
|
||||||
</Typography.Text>
|
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>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -190,7 +375,6 @@ type StoreRow = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
visibilityWhitelistEnabled?: boolean;
|
visibilityWhitelistEnabled?: boolean;
|
||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
isTest?: boolean;
|
|
||||||
cityRef?: { name: string; code: string };
|
cityRef?: { name: string; code: string };
|
||||||
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
||||||
account?: {
|
account?: {
|
||||||
@@ -207,7 +391,6 @@ type StoreRow = {
|
|||||||
closeTime2?: string | null;
|
closeTime2?: string | null;
|
||||||
avgPrice?: number | null;
|
avgPrice?: number | null;
|
||||||
settlementRate?: number;
|
settlementRate?: number;
|
||||||
sortOrder?: number;
|
|
||||||
category?: { id: string; name: string; parentId?: string | null } | null;
|
category?: { id: string; name: string; parentId?: string | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,8 +424,8 @@ export default function StoresPage() {
|
|||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm<StoreCreateForm>();
|
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||||
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
|
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
||||||
const init: Record<string, string | boolean> = {};
|
const init: Record<string, string> = {};
|
||||||
if (initialCityId) init.cityId = initialCityId;
|
if (initialCityId) init.cityId = initialCityId;
|
||||||
if (initialPartnerId) init.partnerId = initialPartnerId;
|
if (initialPartnerId) init.partnerId = initialPartnerId;
|
||||||
return init;
|
return init;
|
||||||
@@ -251,13 +434,12 @@ export default function StoresPage() {
|
|||||||
'/admin/stores',
|
'/admin/stores',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.name) qs.set('name', String(filters.name));
|
if (filters.name) qs.set('name', filters.name);
|
||||||
if (filters.status) qs.set('status', String(filters.status));
|
if (filters.status) qs.set('status', filters.status);
|
||||||
if (filters.auditStatus) qs.set('auditStatus', String(filters.auditStatus));
|
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
|
||||||
if (filters.phone) qs.set('phone', String(filters.phone));
|
if (filters.phone) qs.set('phone', filters.phone);
|
||||||
if (filters.cityId) qs.set('cityId', String(filters.cityId));
|
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||||
if (filters.partnerId) qs.set('partnerId', String(filters.partnerId));
|
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||||
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -266,7 +448,6 @@ export default function StoresPage() {
|
|||||||
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const packagesRef = useRef<AdminStorePackagesHandle>(null);
|
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [auditing, setAuditing] = useState(false);
|
const [auditing, setAuditing] = useState(false);
|
||||||
@@ -381,18 +562,12 @@ export default function StoresPage() {
|
|||||||
account?.phone ||
|
account?.phone ||
|
||||||
(typeof d.phone === 'string' ? d.phone : undefined);
|
(typeof d.phone === 'string' ? d.phone : undefined);
|
||||||
const storePhone = 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);
|
const phoneMismatchNow = !!(loginPhone && storePhone && loginPhone !== storePhone);
|
||||||
setPhoneMismatch(phoneMismatchNow ? String(loginPhone) : null);
|
setPhoneMismatch(phoneMismatchNow ? String(loginPhone) : null);
|
||||||
editForm.setFieldsValue({
|
editForm.setFieldsValue({
|
||||||
name: d.name,
|
name: d.name,
|
||||||
// 登录手机号(老板);与 StoreAccount 同步
|
// 以门店手机号为准保存;若与账号登录号不一致,保存时会强制同步到登录账号
|
||||||
phone: loginPhone || storePhone,
|
phone: storePhone || loginPhone,
|
||||||
contactPhone,
|
|
||||||
intro: d.intro,
|
intro: d.intro,
|
||||||
benefitUsageRule:
|
benefitUsageRule:
|
||||||
d.benefitUsageRule != null &&
|
d.benefitUsageRule != null &&
|
||||||
@@ -401,15 +576,6 @@ export default function StoresPage() {
|
|||||||
? String(d.benefitUsageRule)
|
? String(d.benefitUsageRule)
|
||||||
: '',
|
: '',
|
||||||
coverUrl: d.coverUrl,
|
coverUrl: d.coverUrl,
|
||||||
envPhotoUrls: (() => {
|
|
||||||
const { envs } = collectMediaUrls(d);
|
|
||||||
const urls = envs.map((item) => item.url).filter(Boolean);
|
|
||||||
return urls;
|
|
||||||
})(),
|
|
||||||
contractUrls: (() => {
|
|
||||||
const { contracts } = collectMediaUrls(d);
|
|
||||||
return contracts.map((item) => item.url).filter(Boolean);
|
|
||||||
})(),
|
|
||||||
province: d.province,
|
province: d.province,
|
||||||
city: d.cityName,
|
city: d.cityName,
|
||||||
district: d.district,
|
district: d.district,
|
||||||
@@ -431,8 +597,6 @@ export default function StoresPage() {
|
|||||||
visibilityPhones: Array.isArray(d.visibilityPhones)
|
visibilityPhones: Array.isArray(d.visibilityPhones)
|
||||||
? (d.visibilityPhones as string[])
|
? (d.visibilityPhones as string[])
|
||||||
: [],
|
: [],
|
||||||
isTest: !!d.isTest,
|
|
||||||
sortOrder: d.sortOrder != null ? Number(d.sortOrder) : 0,
|
|
||||||
});
|
});
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}
|
}
|
||||||
@@ -450,14 +614,7 @@ export default function StoresPage() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
name: v.name,
|
name: v.name,
|
||||||
phone: v.phone,
|
phone: v.phone,
|
||||||
contactPhone: String(v.contactPhone || '').trim() || v.phone,
|
coverUrl: v.coverUrl,
|
||||||
coverUrl: v.coverUrl ?? '',
|
|
||||||
envPhotoUrls: Array.isArray(v.envPhotoUrls)
|
|
||||||
? v.envPhotoUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
|
|
||||||
: [],
|
|
||||||
contractUrls: Array.isArray(v.contractUrls)
|
|
||||||
? v.contractUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
|
|
||||||
: [],
|
|
||||||
intro: v.intro,
|
intro: v.intro,
|
||||||
benefitUsageRule:
|
benefitUsageRule:
|
||||||
typeof v.benefitUsageRule === 'string' &&
|
typeof v.benefitUsageRule === 'string' &&
|
||||||
@@ -480,8 +637,9 @@ export default function StoresPage() {
|
|||||||
bankAccountNo: v.bankAccountNo ?? null,
|
bankAccountNo: v.bankAccountNo ?? null,
|
||||||
bankBranch: v.bankBranch ?? null,
|
bankBranch: v.bankBranch ?? null,
|
||||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||||
isTest: !!v.isTest,
|
visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? [])
|
||||||
sortOrder: v.sortOrder != null ? Number(v.sortOrder) : 0,
|
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
||||||
|
.filter(Boolean),
|
||||||
...(hasCoords
|
...(hasCoords
|
||||||
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
|
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -490,10 +648,7 @@ export default function StoresPage() {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
const packagesResult = await packagesRef.current?.saveIfLoaded({ quiet: true });
|
message.success('门店信息已保存');
|
||||||
message.success(
|
|
||||||
packagesResult?.skipped === false ? '门店信息与套餐已保存' : '门店信息已保存',
|
|
||||||
);
|
|
||||||
setDetail(updated);
|
setDetail(updated);
|
||||||
setPhoneMismatch(null);
|
setPhoneMismatch(null);
|
||||||
void reload();
|
void reload();
|
||||||
@@ -567,9 +722,8 @@ export default function StoresPage() {
|
|||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
void loadOptions();
|
void loadOptions();
|
||||||
createForm.setFieldsValue({
|
createForm.setFieldsValue({
|
||||||
envPhotoUrls: [],
|
envPhotoUrls: ['', '', ''],
|
||||||
settlementRate: 60,
|
settlementRate: 60,
|
||||||
sortOrder: 0,
|
|
||||||
openTime: '10:00',
|
openTime: '10:00',
|
||||||
closeTime: '22:00',
|
closeTime: '22:00',
|
||||||
openTime2: undefined,
|
openTime2: undefined,
|
||||||
@@ -620,7 +774,6 @@ export default function StoresPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const envPhotoUrls = (values.envPhotoUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
|
const envPhotoUrls = (values.envPhotoUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
|
||||||
const contractUrls = (values.contractUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
|
|
||||||
await request('/admin/stores', {
|
await request('/admin/stores', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -631,7 +784,6 @@ export default function StoresPage() {
|
|||||||
city: values.city,
|
city: values.city,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
phone: values.phone.trim(),
|
phone: values.phone.trim(),
|
||||||
contactPhone: String(values.contactPhone || values.phone || '').trim(),
|
|
||||||
district: values.district.trim(),
|
district: values.district.trim(),
|
||||||
address: values.address.trim(),
|
address: values.address.trim(),
|
||||||
...(values.latitude != null &&
|
...(values.latitude != null &&
|
||||||
@@ -655,13 +807,15 @@ export default function StoresPage() {
|
|||||||
: {}),
|
: {}),
|
||||||
coverUrl: values.coverUrl?.trim() || undefined,
|
coverUrl: values.coverUrl?.trim() || undefined,
|
||||||
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||||||
contractUrls: contractUrls.length ? contractUrls : undefined,
|
contractUrl: values.contractUrl?.trim() || undefined,
|
||||||
bankAccountName: values.bankAccountName.trim(),
|
bankAccountName: values.bankAccountName.trim(),
|
||||||
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
|
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
|
||||||
bankBranch: values.bankBranch.trim(),
|
bankBranch: values.bankBranch.trim(),
|
||||||
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
||||||
sortOrder: values.sortOrder != null ? Number(values.sortOrder) : 0,
|
|
||||||
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
|
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
|
||||||
|
visibilityPhones: (values.visibilityPhones ?? [])
|
||||||
|
.map((p: string) => String(p || '').replace(/\D/g, '').trim())
|
||||||
|
.filter(Boolean),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
message.success('门店已创建');
|
message.success('门店已创建');
|
||||||
@@ -685,37 +839,14 @@ export default function StoresPage() {
|
|||||||
title: '封面', dataIndex: 'coverUrl', width: 72,
|
title: '封面', dataIndex: 'coverUrl', width: 72,
|
||||||
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
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: '分类',
|
title: '分类',
|
||||||
width: 100,
|
width: 100,
|
||||||
ellipsis: true,
|
|
||||||
render: (_, row) => row.category?.name || '—',
|
render: (_, row) => row.category?.name || '—',
|
||||||
},
|
},
|
||||||
{ title: '城市', dataIndex: 'cityName', width: 80, ellipsis: true },
|
{ title: '城市', dataIndex: 'cityName', width: 80 },
|
||||||
{ title: '登录号', dataIndex: 'phone', width: 120 },
|
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||||
{
|
|
||||||
title: '联系电话',
|
|
||||||
dataIndex: 'contactPhone',
|
|
||||||
width: 120,
|
|
||||||
render: (v: string | null | undefined, row) => v || row.phone,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '营业状态', dataIndex: 'status', width: 90,
|
title: '营业状态', dataIndex: 'status', width: 90,
|
||||||
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
|
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
|
||||||
@@ -726,10 +857,10 @@ export default function StoresPage() {
|
|||||||
const status = s || 'APPROVED';
|
const status = s || 'APPROVED';
|
||||||
const color = status === 'PENDING' ? 'orange' : status === 'REJECTED' ? 'red' : 'green';
|
const color = status === 'PENDING' ? 'orange' : status === 'REJECTED' ? 'red' : 'green';
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
|
<Space direction="vertical" size={0}>
|
||||||
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
|
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
|
||||||
{status === 'REJECTED' && row.rejectReason ? (
|
{status === 'REJECTED' && row.rejectReason ? (
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12, maxWidth: 88 }} ellipsis={{ tooltip: row.rejectReason }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
||||||
{row.rejectReason}
|
{row.rejectReason}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -741,32 +872,21 @@ export default function StoresPage() {
|
|||||||
title: '开城合伙人',
|
title: '开城合伙人',
|
||||||
dataIndex: 'partner',
|
dataIndex: 'partner',
|
||||||
width: 140,
|
width: 140,
|
||||||
ellipsis: { showTitle: false },
|
render: (partner: StoreRow['partner']) =>
|
||||||
render: (partner: StoreRow['partner']) => {
|
partner ? partnerOptionLabel({ id: partner.id ?? '', ...partner }) : '—',
|
||||||
if (!partner) return '—';
|
|
||||||
const label = partnerOptionLabel({ id: partner.id ?? '', ...partner });
|
|
||||||
return (
|
|
||||||
<Typography.Text ellipsis={{ tooltip: label }} style={{ maxWidth: 124 }}>
|
|
||||||
{label}
|
|
||||||
</Typography.Text>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '可见',
|
title: '可见',
|
||||||
dataIndex: 'visibilityWhitelistEnabled',
|
dataIndex: 'visibilityWhitelistEnabled',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v) =>
|
render: (v, row) =>
|
||||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
||||||
},
|
},
|
||||||
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
|
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
|
||||||
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
|
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
|
||||||
{ title: '店长', dataIndex: ['account', 'name'], width: 90, ellipsis: true, render: (v) => v || '—' },
|
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作', width: 140,
|
||||||
width: 140,
|
|
||||||
fixed: 'right',
|
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0}>
|
<Space size={0}>
|
||||||
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
|
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
|
||||||
@@ -819,9 +939,6 @@ export default function StoresPage() {
|
|||||||
options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(STORE_AUDIT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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 type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button
|
<Button
|
||||||
@@ -838,24 +955,8 @@ export default function StoresPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table
|
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
|
||||||
rowKey="id"
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
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)}
|
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
extra={detail && (
|
extra={detail && (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
@@ -952,34 +1053,18 @@ export default function StoresPage() {
|
|||||||
type="warning"
|
type="warning"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
message={`主账号登录号为 ${phoneMismatch},与门店登录字段不一致。保存「登录手机号」将同步到门店端登录账号。`}
|
message={`登录账号手机号仍为 ${phoneMismatch},与门店手机号不一致。请点击右上角「保存修改」同步,否则门店端无法用新号登录。`}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="phone"
|
name="phone"
|
||||||
label="登录手机号(老板)"
|
label="登录手机号"
|
||||||
rules={[{ required: true }]}
|
rules={[{ required: true }]}
|
||||||
extra="门店端主账号短信登录;修改后需用新号重新登录"
|
extra="门店端短信登录使用此号码;修改后需用新号重新登录"
|
||||||
>
|
>
|
||||||
<Input />
|
<Input />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
|
||||||
name="contactPhone"
|
|
||||||
label="联系电话(店长/对外)"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: '请填写对外联系电话' },
|
|
||||||
{
|
|
||||||
validator: (_, value) =>
|
|
||||||
isStoreContactPhone(String(value || ''))
|
|
||||||
? Promise.resolve()
|
|
||||||
: Promise.reject(new Error(STORE_CONTACT_PHONE_HINT)),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
extra="用户端门店详情展示与拨号使用此号码,可与登录号不同,支持座机"
|
|
||||||
>
|
|
||||||
<Input placeholder="手机号或座机,如 0379-8888888" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="categoryParentId"
|
name="categoryParentId"
|
||||||
label="门店分类(大类)"
|
label="门店分类(大类)"
|
||||||
@@ -1074,13 +1159,6 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="avgPrice" label="人均费用(选填)">
|
<Form.Item name="avgPrice" label="人均费用(选填)">
|
||||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
|
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
|
||||||
</Form.Item>
|
</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%' }}>
|
<Space wrap style={{ width: '100%' }}>
|
||||||
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
|
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
|
||||||
<Input type="time" style={{ width: 140 }} />
|
<Input type="time" style={{ width: 140 }} />
|
||||||
@@ -1098,14 +1176,6 @@ export default function StoresPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Space>
|
</Space>
|
||||||
<StoreVisibilityWhitelistFields form={editForm} />
|
<StoreVisibilityWhitelistFields form={editForm} />
|
||||||
<Form.Item
|
|
||||||
name="isTest"
|
|
||||||
label="测试门店"
|
|
||||||
valuePropName="checked"
|
|
||||||
extra="测试门店核销不计入结算账单;联系电话命中全局白名单时会自动标记"
|
|
||||||
>
|
|
||||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -1150,14 +1220,12 @@ export default function StoresPage() {
|
|||||||
{
|
{
|
||||||
key: 'media',
|
key: 'media',
|
||||||
label: '审核材料',
|
label: '审核材料',
|
||||||
forceRender: true,
|
children: <StoreAuditMediaSection detail={detail} />,
|
||||||
children: <StoreAuditMediaEditor />,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'packages',
|
key: 'packages',
|
||||||
label: '套餐',
|
label: '套餐',
|
||||||
forceRender: true,
|
children: <AdminStorePackagesSection storeId={String(detail.id)} />,
|
||||||
children: <AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -1255,27 +1323,8 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
||||||
<Input placeholder="请输入门店名称" />
|
<Input placeholder="请输入门店名称" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="phone" label="登录手机号(老板)" rules={[{ required: true, message: '请填写登录手机号' }]}>
|
<Form.Item name="phone" label="门店手机号(登录账号)" rules={[{ required: true, message: '请填写门店手机号' }]}>
|
||||||
<Input placeholder="门店端主账号登录" />
|
<Input placeholder="11位手机号" />
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="contactPhone"
|
|
||||||
label="联系电话(店长/对外)"
|
|
||||||
extra="用户端拨号展示;留空则与登录号相同。支持座机"
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
validator: (_, value) => {
|
|
||||||
const raw = String(value || '').trim();
|
|
||||||
if (!raw || isStoreContactPhone(raw)) return Promise.resolve();
|
|
||||||
return Promise.reject(new Error(STORE_CONTACT_PHONE_HINT));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input placeholder="手机号或座机,如 0379-8888888" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="sortOrder" label="排序" extra="数值越小越靠前">
|
|
||||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="0" />
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
||||||
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
||||||
@@ -1361,31 +1410,26 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="coverUrl" label="门头照">
|
<Form.Item name="coverUrl" label="门头照">
|
||||||
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Typography.Text strong>环境照片</Typography.Text>
|
||||||
name="envPhotoUrls"
|
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
|
||||||
label="环境照片"
|
至少 3 张,可继续添加
|
||||||
extra="选填;支持批量上传,最多 20 张"
|
</Typography.Paragraph>
|
||||||
>
|
<Form.List name="envPhotoUrls">
|
||||||
<MultiImageUpload
|
{(fields, { add }) => (
|
||||||
bizType="STORE_ENV"
|
<div style={{ marginTop: 8 }}>
|
||||||
mediaType="IMAGE"
|
{fields.map((field, index) => (
|
||||||
maxCount={20}
|
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`}>
|
||||||
tip="环境照支持一次选择多张批量上传"
|
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
|
||||||
/>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
))}
|
||||||
name="contractUrls"
|
<Button type="dashed" onClick={() => add('')} block>
|
||||||
label="签约合同"
|
添加环境照片
|
||||||
extra="选填;支持多张合同照片或 PDF,最多 20 个"
|
</Button>
|
||||||
>
|
</div>
|
||||||
<MultiImageUpload
|
)}
|
||||||
bizType="STORE_CONTRACT"
|
</Form.List>
|
||||||
mediaType="FILE"
|
<Form.Item name="contractUrl" label="签约合同">
|
||||||
accept="image/*,.pdf"
|
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
|
||||||
maxCount={20}
|
|
||||||
buttonText="批量上传合同"
|
|
||||||
tip="合同支持一次选择多张照片批量上传,最多 20 个"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: createStep === 2 ? 'block' : 'none' }}>
|
<div style={{ display: createStep === 2 ? 'block' : 'none' }}>
|
||||||
|
|||||||
@@ -1,687 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -30,13 +30,6 @@ type UserOrderRow = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */
|
|
||||||
function fmtBenefit(v: number | null | undefined) {
|
|
||||||
const n = Number(v ?? 0);
|
|
||||||
if (!Number.isFinite(n) || n <= 0) return '—';
|
|
||||||
return `¥${n.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserBehaviorLog = {
|
type UserBehaviorLog = {
|
||||||
id: string;
|
id: string;
|
||||||
eventName: string;
|
eventName: string;
|
||||||
@@ -119,7 +112,6 @@ export default function UsersPage() {
|
|||||||
if (values.status !== undefined && values.status !== '') {
|
if (values.status !== undefined && values.status !== '') {
|
||||||
qs.set('status', String(values.status));
|
qs.set('status', String(values.status));
|
||||||
}
|
}
|
||||||
if (values.excludeTest) qs.set('excludeTest', 'true');
|
|
||||||
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
|
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
|
||||||
setData(res);
|
setData(res);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -245,17 +237,7 @@ export default function UsersPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const columns: ColumnsType<AdminUserRow> = [
|
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: '昵称', dataIndex: 'nickname', width: 100 },
|
||||||
{
|
{
|
||||||
title: '手机',
|
title: '手机',
|
||||||
@@ -322,39 +304,6 @@ export default function UsersPage() {
|
|||||||
render: (v) => (v ? <Tag color="blue">已合并</Tag> : '—'),
|
render: (v) => (v ? <Tag color="blue">已合并</Tag> : '—'),
|
||||||
},
|
},
|
||||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||||
{
|
|
||||||
title: '剩余权益',
|
|
||||||
dataIndex: 'benefitBalance',
|
|
||||||
width: 110,
|
|
||||||
align: 'right',
|
|
||||||
sorter: (a, b) => Number(a.benefitBalance ?? 0) - Number(b.benefitBalance ?? 0),
|
|
||||||
render: (v: number | undefined) =>
|
|
||||||
Number(v ?? 0) > 0 ? (
|
|
||||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
|
||||||
{fmtBenefit(v)}
|
|
||||||
</Typography.Text>
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">—</Typography.Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '已用权益',
|
|
||||||
dataIndex: 'benefitUsedAmount',
|
|
||||||
width: 110,
|
|
||||||
align: 'right',
|
|
||||||
sorter: (a, b) => Number(a.benefitUsedAmount ?? 0) - Number(b.benefitUsedAmount ?? 0),
|
|
||||||
render: (v: number | undefined) => (
|
|
||||||
<Typography.Text type="secondary">{fmtBenefit(v)}</Typography.Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '累计权益',
|
|
||||||
dataIndex: 'benefitTotalAmount',
|
|
||||||
width: 110,
|
|
||||||
align: 'right',
|
|
||||||
sorter: (a, b) => Number(a.benefitTotalAmount ?? 0) - Number(b.benefitTotalAmount ?? 0),
|
|
||||||
render: (v: number | undefined) => fmtBenefit(v),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '注册时间',
|
title: '注册时间',
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
@@ -413,9 +362,6 @@ export default function UsersPage() {
|
|||||||
{ value: 0, label: '停用' },
|
{ value: 0, label: '停用' },
|
||||||
]} />
|
]} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="excludeTest" valuePropName="checked">
|
|
||||||
<Checkbox>过滤测试账号</Checkbox>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
@@ -429,7 +375,7 @@ export default function UsersPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1830 }}
|
scroll={{ x: 1500 }}
|
||||||
rowSelection={canDeleteUsers ? {
|
rowSelection={canDeleteUsers ? {
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
preserveSelectedRowKeys: true,
|
preserveSelectedRowKeys: true,
|
||||||
@@ -501,18 +447,6 @@ export default function UsersPage() {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
||||||
<Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</Descriptions.Item>
|
<Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</Descriptions.Item>
|
||||||
<Descriptions.Item label="好客权益">
|
|
||||||
<Space size={16} wrap>
|
|
||||||
<span>
|
|
||||||
剩余{' '}
|
|
||||||
<Typography.Text strong style={{ color: '#cf1322' }}>
|
|
||||||
{fmtBenefit(detail.benefitBalance)}
|
|
||||||
</Typography.Text>
|
|
||||||
</span>
|
|
||||||
<span>已用 {fmtBenefit(detail.benefitUsedAmount)}</span>
|
|
||||||
<span>累计 {fmtBenefit(detail.benefitTotalAmount)}</span>
|
|
||||||
</Space>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="注册时间">
|
<Descriptions.Item label="注册时间">
|
||||||
{new Date(detail.createdAt).toLocaleString('zh-CN')}
|
{new Date(detail.createdAt).toLocaleString('zh-CN')}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|||||||
@@ -51,35 +51,13 @@ type BillItem = {
|
|||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
UNPAID: '未打款',
|
UNPAID: '未打款',
|
||||||
PAID: '已打款',
|
PAID: '已打款',
|
||||||
NO_PAYMENT_NEEDED: '无需打款',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
UNPAID: 'red',
|
UNPAID: 'red',
|
||||||
PAID: 'green',
|
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> = {
|
const DELIVERY_LABELS: Record<string, string> = {
|
||||||
LOCAL: '同城',
|
LOCAL: '同城',
|
||||||
CROSS_CITY: '跨城',
|
CROSS_CITY: '跨城',
|
||||||
@@ -234,7 +212,7 @@ export default function WineryBillsPage() {
|
|||||||
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '应付',
|
title: '酒厂应付',
|
||||||
dataIndex: 'wineryAmount',
|
dataIndex: 'wineryAmount',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
@@ -242,11 +220,8 @@ export default function WineryBillsPage() {
|
|||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 100,
|
width: 90,
|
||||||
render: (s, row) => {
|
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
||||||
const d = displayWineryStatus(s, row.wineryAmount);
|
|
||||||
return <Tag color={d.color}>{d.label}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -257,7 +232,7 @@ export default function WineryBillsPage() {
|
|||||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||||
明细
|
明细
|
||||||
</Button>
|
</Button>
|
||||||
{canConfirmWineryPay(row) && (
|
{row.status === 'UNPAID' && (
|
||||||
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
|
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
|
||||||
确认打款
|
确认打款
|
||||||
</Button>
|
</Button>
|
||||||
@@ -283,8 +258,7 @@ export default function WineryBillsPage() {
|
|||||||
酒厂对账单
|
酒厂对账单
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色、应付为 0
|
T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
||||||
无需打款(灰),可展开订单明细
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
{canEditWineryBank ? (
|
{canEditWineryBank ? (
|
||||||
@@ -299,7 +273,7 @@ export default function WineryBillsPage() {
|
|||||||
<Space size="large" wrap>
|
<Space size="large" wrap>
|
||||||
<Statistic title="账单数" value={summary.count} />
|
<Statistic title="账单数" value={summary.count} />
|
||||||
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
<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>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -322,7 +296,7 @@ export default function WineryBillsPage() {
|
|||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 130 }}
|
style={{ width: 120 }}
|
||||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -373,7 +347,7 @@ export default function WineryBillsPage() {
|
|||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedKeys,
|
selectedRowKeys: selectedKeys,
|
||||||
onChange: setSelectedKeys,
|
onChange: setSelectedKeys,
|
||||||
getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
|
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 1100 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
@@ -394,10 +368,8 @@ export default function WineryBillsPage() {
|
|||||||
<Descriptions column={1} size="small" bordered>
|
<Descriptions column={1} size="small" bordered>
|
||||||
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</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="酒厂应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
|
||||||
{displayWineryStatus(detail.status, detail.wineryAmount).label}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
|
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||||
@@ -423,7 +395,7 @@ export default function WineryBillsPage() {
|
|||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '应付',
|
title: '酒厂应付',
|
||||||
dataIndex: 'wineryAmount',
|
dataIndex: 'wineryAmount',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
|||||||
@@ -11,10 +11,7 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true
|
||||||
"paths": {
|
|
||||||
"@dukang/domain": ["../../packages/domain/src/index.ts"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:3010';
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@dukang/domain': path.resolve(__dirname, '../../packages/domain/src/index.ts'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
|
||||||
port: 5175,
|
port: 5175,
|
||||||
proxy: { '/api': apiTarget },
|
proxy: { '/api': 'http://localhost:3000' },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dukang/client-logging": "workspace:*",
|
"@dukang/client-logging": "workspace:*",
|
||||||
"@dukang/domain": "workspace:*",
|
|
||||||
"@dukang/shared-types": "workspace:*",
|
"@dukang/shared-types": "workspace:*",
|
||||||
"@dukang/shared-ui": "workspace:*",
|
"@dukang/shared-ui": "workspace:*",
|
||||||
"@dukang/weixin-sdk": "workspace:*",
|
"@dukang/weixin-sdk": "workspace:*",
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
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;
|
|
||||||
/** 系统文件选择器的 accept,默认仅图片 */
|
|
||||||
accept?: string;
|
|
||||||
/** 计量单位文案,如「张」「个」 */
|
|
||||||
unit?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isCancelError(msg: string): boolean {
|
|
||||||
return /cancel|取消/i.test(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPdf(url: string): boolean {
|
|
||||||
return /\.pdf(\?|$)/i.test(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
accept = 'image/*',
|
|
||||||
unit = '张',
|
|
||||||
}: 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} ${unit}`);
|
|
||||||
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} ${unit}`);
|
|
||||||
}
|
|
||||||
} 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={accept}
|
|
||||||
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 }}>
|
|
||||||
{isPdf(url) ? (
|
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
width: 88,
|
|
||||||
height: 88,
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 2,
|
|
||||||
borderRadius: 8,
|
|
||||||
border: '1px solid rgba(0,0,0,0.08)',
|
|
||||||
background: '#f7f7f7',
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 26 }}>
|
|
||||||
description
|
|
||||||
</span>
|
|
||||||
<span className="text-muted">PDF</span>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<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}${unit}`
|
|
||||||
: 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 { useState } from 'react';
|
||||||
import type { PackageFormItem } from '../lib/storePackages';
|
import type { PackageFormItem } from '../lib/storePackages';
|
||||||
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
import { emptyPackage } from '../lib/storePackages';
|
import { emptyPackage } from '../lib/storePackages';
|
||||||
import MultiOssUploadField from './MultiOssUploadField';
|
import OssUploadField from './OssUploadField';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
items: PackageFormItem[];
|
items: PackageFormItem[];
|
||||||
@@ -119,47 +119,39 @@ export default function StorePackagesForm({ items, onChange, disabled, embedded
|
|||||||
|
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>使用时间</label>
|
<label>使用时间</label>
|
||||||
<textarea
|
<div className="partner-field-input">
|
||||||
rows={2}
|
<span className="material-symbols-outlined">schedule</span>
|
||||||
|
<input
|
||||||
placeholder="节假日除外"
|
placeholder="节假日除外"
|
||||||
value={item.usableTime || ''}
|
value={item.usableTime || ''}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>套餐图片(最多 {STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量)</label>
|
<label>套餐图片</label>
|
||||||
<MultiOssUploadField
|
<OssUploadField
|
||||||
bizType="STORE_PACKAGE"
|
bizType="STORE_PACKAGE"
|
||||||
maxCount={STORE_PACKAGE_IMAGE_MAX_COUNT}
|
value={item.imageUrl || ''}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={
|
onChange={(url) => updateAt(index, { imageUrl: url })}
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>其他说明</label>
|
<label>其他说明</label>
|
||||||
<textarea
|
<div className="partner-field-input">
|
||||||
rows={2}
|
<span className="material-symbols-outlined">info</span>
|
||||||
|
<input
|
||||||
placeholder="不可叠加"
|
placeholder="不可叠加"
|
||||||
value={item.otherNotes || ''}
|
value={item.otherNotes || ''}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import { getDefaultPartnerRegionForm } from './china-region';
|
|
||||||
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
|
|
||||||
|
|
||||||
export type StoreDraftForm = {
|
export type StoreDraftForm = {
|
||||||
regionCodes: string[];
|
regionCodes: string[];
|
||||||
cityId: string;
|
cityId: string;
|
||||||
@@ -8,10 +5,7 @@ export type StoreDraftForm = {
|
|||||||
city: string;
|
city: string;
|
||||||
district: string;
|
district: string;
|
||||||
name: string;
|
name: string;
|
||||||
/** 门店登录手机号(老板主账号) */
|
|
||||||
phone: string;
|
phone: string;
|
||||||
/** 对外联系电话(店长);可与登录号不同 */
|
|
||||||
contactPhone: string;
|
|
||||||
address: string;
|
address: string;
|
||||||
/** 门店坐标(定位或地理编码) */
|
/** 门店坐标(定位或地理编码) */
|
||||||
latitude: string;
|
latitude: string;
|
||||||
@@ -31,8 +25,7 @@ export type StoreDraftForm = {
|
|||||||
benefitUsageRule: string;
|
benefitUsageRule: string;
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
envPhotoUrls: string[];
|
envPhotoUrls: string[];
|
||||||
/** 签约合同,支持多张照片 / PDF */
|
contractUrl: string;
|
||||||
contractUrls: string[];
|
|
||||||
bankAccountName: string;
|
bankAccountName: string;
|
||||||
bankAccountNo: string;
|
bankAccountNo: string;
|
||||||
bankBranch: string;
|
bankBranch: string;
|
||||||
@@ -51,12 +44,13 @@ export function storeDraftKey(accountId?: string): string {
|
|||||||
return accountId ? `${STORE_DRAFT_KEY}_${accountId}` : STORE_DRAFT_KEY;
|
return accountId ? `${STORE_DRAFT_KEY}_${accountId}` : STORE_DRAFT_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { getDefaultPartnerRegionForm } from './china-region';
|
||||||
|
|
||||||
export const defaultStoreForm = (): StoreDraftForm => ({
|
export const defaultStoreForm = (): StoreDraftForm => ({
|
||||||
...getDefaultPartnerRegionForm(),
|
...getDefaultPartnerRegionForm(),
|
||||||
cityId: '',
|
cityId: '',
|
||||||
name: '',
|
name: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
contactPhone: '',
|
|
||||||
address: '',
|
address: '',
|
||||||
latitude: '',
|
latitude: '',
|
||||||
longitude: '',
|
longitude: '',
|
||||||
@@ -72,7 +66,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
|||||||
benefitUsageRule: '',
|
benefitUsageRule: '',
|
||||||
coverUrl: '',
|
coverUrl: '',
|
||||||
envPhotoUrls: ['', '', ''],
|
envPhotoUrls: ['', '', ''],
|
||||||
contractUrls: [],
|
contractUrl: '',
|
||||||
bankAccountName: '',
|
bankAccountName: '',
|
||||||
bankAccountNo: '',
|
bankAccountNo: '',
|
||||||
bankBranch: '',
|
bankBranch: '',
|
||||||
@@ -90,9 +84,6 @@ function timeToMinutes(hhmm: string): number {
|
|||||||
|
|
||||||
export const MIN_ENV_PHOTO_COUNT = 3;
|
export const MIN_ENV_PHOTO_COUNT = 3;
|
||||||
|
|
||||||
/** 签约合同最多可上传的照片 / PDF 数量 */
|
|
||||||
export const MAX_CONTRACT_COUNT = 20;
|
|
||||||
|
|
||||||
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
|
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
|
||||||
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
|
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
|
||||||
while (arr.length < minLen) arr.push('');
|
while (arr.length < minLen) arr.push('');
|
||||||
@@ -111,9 +102,6 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
|
|||||||
...raw,
|
...raw,
|
||||||
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
|
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
|
||||||
cityId: String(raw.cityId ?? base.cityId),
|
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,
|
latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude,
|
||||||
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
|
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
|
||||||
openTime: String(raw.openTime ?? base.openTime),
|
openTime: String(raw.openTime ?? base.openTime),
|
||||||
@@ -123,22 +111,6 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
|
|||||||
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
||||||
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
||||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT),
|
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT),
|
||||||
// 兼容旧草稿:单个 contractUrl 迁移为数组
|
|
||||||
contractUrls: (() => {
|
|
||||||
const list = Array.isArray(raw.contractUrls)
|
|
||||||
? raw.contractUrls
|
|
||||||
: [(raw as { contractUrl?: unknown }).contractUrl];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: string[] = [];
|
|
||||||
for (const item of list) {
|
|
||||||
const url = String(item ?? '').trim();
|
|
||||||
if (!url || seen.has(url)) continue;
|
|
||||||
seen.add(url);
|
|
||||||
out.push(url);
|
|
||||||
if (out.length >= MAX_CONTRACT_COUNT) break;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
})(),
|
|
||||||
packages: Array.isArray(raw.packages)
|
packages: Array.isArray(raw.packages)
|
||||||
? raw.packages.map((p, i) => ({
|
? raw.packages.map((p, i) => ({
|
||||||
name: String((p as { name?: string }).name ?? ''),
|
name: String((p as { name?: string }).name ?? ''),
|
||||||
@@ -245,13 +217,12 @@ export function validateStoreStep1(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function validateStoreStep2(
|
export function validateStoreStep2(
|
||||||
form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrls'>,
|
form: Pick<StoreDraftForm, 'coverUrl' | 'envPhotoUrls' | 'contractUrl'>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.coverUrl.trim()) return '请上传门头照';
|
if (!form.coverUrl.trim()) return '请上传门头照';
|
||||||
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
|
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
|
||||||
if (envCount < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`;
|
if (envCount < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`;
|
||||||
const contractCount = (form.contractUrls ?? []).filter((u) => u.trim()).length;
|
if (!form.contractUrl.trim()) return '请上传签约合同';
|
||||||
if (contractCount < 1) return '请上传签约合同';
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,16 +236,14 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
|
|||||||
export function validateStoreStep3(
|
export function validateStoreStep3(
|
||||||
form: Pick<
|
form: Pick<
|
||||||
StoreDraftForm,
|
StoreDraftForm,
|
||||||
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' | 'contactPhone'
|
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone'
|
||||||
>,
|
>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.bankAccountName.trim()) return '请填写户主姓名';
|
if (!form.bankAccountName.trim()) return '请填写户主姓名';
|
||||||
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
|
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
|
||||||
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
|
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
|
||||||
if (!form.bankBranch.trim()) return '请填写开户支行';
|
if (!form.bankBranch.trim()) return '请填写开户支行';
|
||||||
if (!form.phone.trim()) return '请填写门店登录手机号';
|
if (!form.phone.trim()) return '请填写联系电话';
|
||||||
if (!PHONE_RE.test(form.phone.trim())) return '门店登录手机号须为11位手机号';
|
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
||||||
if (!form.contactPhone.trim()) return '请填写联系电话';
|
|
||||||
if (!isStoreContactPhone(form.contactPhone.trim())) return STORE_CONTACT_PHONE_HINT;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||||
import {
|
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
STORE_PACKAGE_IMAGE_MAX_COUNT,
|
|
||||||
STORE_PACKAGE_MAX_COUNT,
|
|
||||||
normalizeStorePackageImageUrls,
|
|
||||||
} from '@dukang/shared-types';
|
|
||||||
|
|
||||||
export type PackageFormItem = StorePackageItemDto;
|
export type PackageFormItem = StorePackageItemDto;
|
||||||
|
|
||||||
@@ -15,34 +11,24 @@ export function emptyPackage(index = 0): PackageFormItem {
|
|||||||
usableTime: '',
|
usableTime: '',
|
||||||
otherNotes: '',
|
otherNotes: '',
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
imageUrls: [],
|
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
|
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
|
||||||
return raw
|
return raw
|
||||||
.map((item, index) => {
|
.map((item, index) => ({
|
||||||
const imageUrls = normalizeStorePackageImageUrls(item);
|
|
||||||
return {
|
|
||||||
name: item.name.trim(),
|
name: item.name.trim(),
|
||||||
price: item.price.trim(),
|
price: item.price.trim(),
|
||||||
dishes: item.dishes.trim(),
|
dishes: item.dishes.trim(),
|
||||||
usableTime: item.usableTime?.trim() || '',
|
usableTime: item.usableTime?.trim() || '',
|
||||||
otherNotes: item.otherNotes?.trim() || '',
|
otherNotes: item.otherNotes?.trim() || '',
|
||||||
imageUrl: imageUrls[0] ?? '',
|
imageUrl: item.imageUrl?.trim() || '',
|
||||||
imageUrls,
|
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
};
|
}))
|
||||||
})
|
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.name ||
|
item.name || item.price || item.dishes || item.usableTime || item.otherNotes || item.imageUrl,
|
||||||
item.price ||
|
|
||||||
item.dishes ||
|
|
||||||
item.usableTime ||
|
|
||||||
item.otherNotes ||
|
|
||||||
item.imageUrls.length > 0,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +43,6 @@ export function validatePackageFormItems(items: PackageFormItem[]): string | nul
|
|||||||
const price = Number(item.price);
|
const price = Number(item.price);
|
||||||
if (!Number.isFinite(price) || price < 0) return `第 ${i + 1} 条套餐价格须为非负数字`;
|
if (!Number.isFinite(price) || price < 0) return `第 ${i + 1} 条套餐价格须为非负数字`;
|
||||||
if (!item.dishes) 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export type PartnerStoreAuditStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | stri
|
|||||||
|
|
||||||
export function storeAuditLabel(auditStatus?: string | null): string {
|
export function storeAuditLabel(auditStatus?: string | null): string {
|
||||||
const s = String(auditStatus || 'APPROVED').toUpperCase();
|
const s = String(auditStatus || 'APPROVED').toUpperCase();
|
||||||
if (s === 'PENDING') return '待审核';
|
if (s === 'PENDING') return '待总部审核';
|
||||||
if (s === 'REJECTED') return '审核驳回';
|
if (s === 'REJECTED') return '审核驳回';
|
||||||
if (s === 'APPROVED') return '审核通过';
|
if (s === 'APPROVED') return '审核通过';
|
||||||
return auditStatus || '—';
|
return auditStatus || '—';
|
||||||
@@ -21,26 +21,10 @@ export function storeStatusLabel(status: string): string {
|
|||||||
const s = String(status).toUpperCase();
|
const s = String(status).toUpperCase();
|
||||||
if (s === 'OPEN') return '营业中';
|
if (s === 'OPEN') return '营业中';
|
||||||
if (s === 'PAUSED') return '临时闭店';
|
if (s === 'PAUSED') return '临时闭店';
|
||||||
if (s === 'CLOSED') return '永久闭店';
|
if (s === 'CLOSED') return '永久关闭';
|
||||||
return status;
|
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 {
|
export function storeStatusPillClass(status: string): string {
|
||||||
const s = String(status).toUpperCase();
|
const s = String(status).toUpperCase();
|
||||||
if (s === 'OPEN') return 'partner-status-pill--open';
|
if (s === 'OPEN') return 'partner-status-pill--open';
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
|||||||
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
||||||
|
|
||||||
import OssUploadField from '../components/OssUploadField';
|
import OssUploadField from '../components/OssUploadField';
|
||||||
import MultiOssUploadField from '../components/MultiOssUploadField';
|
|
||||||
|
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fetchClientConfig } from '../lib/wechat-auth';
|
|
||||||
import { toastError, toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
|
|
||||||
import { resolveRegionBinding } from '../lib/china-region';
|
import { resolveRegionBinding } from '../lib/china-region';
|
||||||
@@ -42,18 +40,15 @@ import {
|
|||||||
|
|
||||||
validateStoreStep3,
|
validateStoreStep3,
|
||||||
|
|
||||||
|
patchEnvPhotoAt,
|
||||||
|
addEnvPhotoSlot,
|
||||||
MIN_ENV_PHOTO_COUNT,
|
MIN_ENV_PHOTO_COUNT,
|
||||||
|
|
||||||
MAX_CONTRACT_COUNT,
|
|
||||||
} from '../lib/storeDraft';
|
} from '../lib/storeDraft';
|
||||||
import StorePackagesForm from '../components/StorePackagesForm';
|
import StorePackagesForm from '../components/StorePackagesForm';
|
||||||
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
|
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
|
||||||
|
|
||||||
const STEPS = ['基本信息', '照片上传', '结算资质', '门店套餐'] as const;
|
const STEPS = ['基本信息', '照片上传', '结算资质', '门店套餐'] as const;
|
||||||
|
|
||||||
const DEFAULT_PARTNER_ONBOARD_CS_HINT =
|
|
||||||
'使用问题、提现问题等随时可联系【杜康好客】客服';
|
|
||||||
|
|
||||||
type StoreCategoryNode = {
|
type StoreCategoryNode = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -62,7 +57,6 @@ type StoreCategoryNode = {
|
|||||||
|
|
||||||
type FieldErrors = {
|
type FieldErrors = {
|
||||||
phone?: string;
|
phone?: string;
|
||||||
contactPhone?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -104,10 +98,6 @@ export default function StoreCreatePage() {
|
|||||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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[]>([]);
|
const [cities, setCities] = useState<OpenCityOption[]>([]);
|
||||||
|
|
||||||
@@ -159,29 +149,6 @@ export default function StoreCreatePage() {
|
|||||||
});
|
});
|
||||||
}, [step]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
void fetchPartnerCities()
|
void fetchPartnerCities()
|
||||||
@@ -271,17 +238,9 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
setSubmitError('');
|
setSubmitError('');
|
||||||
|
|
||||||
if ('phone' in patch || 'contactPhone' in patch) {
|
if ('phone' in patch) {
|
||||||
|
|
||||||
setFieldErrors((prev) => ({
|
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
|
||||||
|
|
||||||
...prev,
|
|
||||||
|
|
||||||
...('phone' in patch ? { phone: undefined } : {}),
|
|
||||||
|
|
||||||
...('contactPhone' in patch ? { contactPhone: undefined } : {}),
|
|
||||||
|
|
||||||
}));
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +250,22 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function patchEnvPhotoUrl(index: number, url: string) {
|
||||||
|
|
||||||
|
setForm((prev) => ({
|
||||||
|
|
||||||
|
...prev,
|
||||||
|
|
||||||
|
envPhotoUrls: patchEnvPhotoAt(prev.envPhotoUrls, index, url),
|
||||||
|
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSubmitError('');
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function bindRegionSelection(codes: string[]) {
|
function bindRegionSelection(codes: string[]) {
|
||||||
|
|
||||||
const binding = resolveRegionBinding(codes, cities);
|
const binding = resolveRegionBinding(codes, cities);
|
||||||
@@ -357,9 +332,7 @@ export default function StoreCreatePage() {
|
|||||||
const msg = validateStoreStep3(form);
|
const msg = validateStoreStep3(form);
|
||||||
if (msg) {
|
if (msg) {
|
||||||
if (isPhoneValidationMessage(msg)) {
|
if (isPhoneValidationMessage(msg)) {
|
||||||
setFieldErrors(
|
setFieldErrors({ phone: msg });
|
||||||
msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
|
|
||||||
);
|
|
||||||
reportStepError(msg);
|
reportStepError(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -375,23 +348,12 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
|
|
||||||
async function submit(skipPackages = false) {
|
async function submit(skipPackages = false) {
|
||||||
const qr = (csQrUrl ?? '').trim();
|
|
||||||
if (!qr) {
|
|
||||||
reportFormError('客服二维码暂未配置,请联系总部');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!csAdded) {
|
|
||||||
reportFormError('请先勾选「我已添加【杜康好客】客服」');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const msg = validateStoreStep3(form);
|
const msg = validateStoreStep3(form);
|
||||||
|
|
||||||
if (msg) {
|
if (msg) {
|
||||||
if (isPhoneValidationMessage(msg)) {
|
if (isPhoneValidationMessage(msg)) {
|
||||||
setFieldErrors(
|
setFieldErrors({ phone: msg });
|
||||||
msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reportFormError(msg);
|
reportFormError(msg);
|
||||||
@@ -478,11 +440,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
const envPhotoUrls = Array.from(
|
const envPhotoUrls = Array.from(
|
||||||
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
|
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
|
||||||
).slice(0, 20);
|
).slice(0, 3);
|
||||||
|
|
||||||
const contractUrls = Array.from(
|
|
||||||
new Set((form.contractUrls ?? []).map((u) => u.trim()).filter(Boolean)),
|
|
||||||
).slice(0, MAX_CONTRACT_COUNT);
|
|
||||||
|
|
||||||
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
|
||||||
|
|
||||||
@@ -500,8 +458,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
phone: form.phone.trim(),
|
phone: form.phone.trim(),
|
||||||
|
|
||||||
contactPhone: form.contactPhone.trim() || form.phone.trim(),
|
|
||||||
|
|
||||||
district: form.district.trim(),
|
district: form.district.trim(),
|
||||||
|
|
||||||
address: form.address.trim(),
|
address: form.address.trim(),
|
||||||
@@ -532,7 +488,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||||||
|
|
||||||
contractUrls: contractUrls.length ? contractUrls : undefined,
|
contractUrl: form.contractUrl.trim() || undefined,
|
||||||
|
|
||||||
bankAccountName: form.bankAccountName.trim(),
|
bankAccountName: form.bankAccountName.trim(),
|
||||||
|
|
||||||
@@ -583,7 +539,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
const progress = step === 1 ? 0 : step === 2 ? 33 : step === 3 ? 66 : 100;
|
const progress = step === 1 ? 0 : step === 2 ? 33 : step === 3 ? 66 : 100;
|
||||||
|
|
||||||
const canSubmitOnboard = !!csQrUrl?.trim() && csAdded && !submitting && !csConfigLoading;
|
|
||||||
const nextDisabled = submitting;
|
const nextDisabled = submitting;
|
||||||
|
|
||||||
|
|
||||||
@@ -1002,15 +957,46 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
|
|
||||||
<section className="partner-form-card">
|
<section className="partner-form-card">
|
||||||
|
|
||||||
<h3 className="headline-md">环境照片 <span className="text-primary">*</span></h3>
|
<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} 张,支持批量上传,最多 20 张</p>
|
|
||||||
<MultiOssUploadField
|
<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
|
||||||
|
|
||||||
bizType="STORE_ENV"
|
bizType="STORE_ENV"
|
||||||
maxCount={20}
|
|
||||||
value={form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)}
|
mediaType="IMAGE"
|
||||||
onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))}
|
|
||||||
label={`批量上传(${form.envPhotoUrls.map((u) => u.trim()).filter(Boolean).length}/20)`}
|
value={url}
|
||||||
|
|
||||||
|
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
))}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-outline"
|
||||||
|
style={{ width: '100%', marginTop: 12 }}
|
||||||
|
onClick={() =>
|
||||||
|
setForm((prev) => ({ ...prev, envPhotoUrls: addEnvPhotoSlot(prev.envPhotoUrls) }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
添加环境照片
|
||||||
|
</button>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
@@ -1019,11 +1005,9 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
|
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
|
||||||
|
|
||||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>
|
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>拍照上传签约协议首页与盖章页</p>
|
||||||
拍照上传签约协议首页与盖章页,支持多张,最多 {MAX_CONTRACT_COUNT} 个
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<MultiOssUploadField
|
<OssUploadField
|
||||||
|
|
||||||
bizType="STORE_CONTRACT"
|
bizType="STORE_CONTRACT"
|
||||||
|
|
||||||
@@ -1031,15 +1015,11 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
accept="image/*,.pdf"
|
accept="image/*,.pdf"
|
||||||
|
|
||||||
unit="个"
|
value={form.contractUrl}
|
||||||
|
|
||||||
maxCount={MAX_CONTRACT_COUNT}
|
onChange={(contractUrl) => patchForm({ contractUrl })}
|
||||||
|
|
||||||
value={form.contractUrls ?? []}
|
label="上传合同副本"
|
||||||
|
|
||||||
onChange={(urls) => patchForm({ contractUrls: urls })}
|
|
||||||
|
|
||||||
label={`批量上传合同(${(form.contractUrls ?? []).length}/${MAX_CONTRACT_COUNT})`}
|
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1157,43 +1137,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
<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="手机号或座机,如 0379-8888888"
|
|
||||||
|
|
||||||
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 }}>
|
|
||||||
|
|
||||||
用户端门店详情展示与拨号使用此号码,可与登录号不同,支持座机(如 0379-8888888)。
|
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -1206,37 +1150,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
{step === 4 && (
|
{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">
|
<section className="partner-form-card">
|
||||||
<div className="partner-section-title">
|
<div className="partner-section-title">
|
||||||
<div className="partner-section-bar" />
|
<div className="partner-section-bar" />
|
||||||
@@ -1282,21 +1195,13 @@ export default function StoreCreatePage() {
|
|||||||
) : (
|
) : (
|
||||||
|
|
||||||
<>
|
<>
|
||||||
<button
|
<button type="button" className="partner-btn-outline" onClick={() => void submit(true)} disabled={submitting}>
|
||||||
type="button"
|
|
||||||
className="partner-btn-outline"
|
|
||||||
onClick={() => void submit(true)}
|
|
||||||
disabled={!canSubmitOnboard}
|
|
||||||
>
|
|
||||||
跳过
|
跳过
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" className="partner-btn-primary" onClick={() => void submit(false)} disabled={submitting}>
|
||||||
type="button"
|
|
||||||
className="partner-btn-primary"
|
|
||||||
onClick={() => void submit(false)}
|
|
||||||
disabled={!canSubmitOnboard}
|
|
||||||
>
|
|
||||||
{submitting ? '提交中…' : '提交'}
|
{submitting ? '提交中…' : '提交'}
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import { request } from '../lib/api';
|
|||||||
import { toastError, toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { canManagePartnerStore } from '../lib/partnerAccess';
|
import { canManagePartnerStore } from '../lib/partnerAccess';
|
||||||
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray } from '../lib/storeDraft';
|
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray, patchEnvPhotoAt, addEnvPhotoSlot } from '../lib/storeDraft';
|
||||||
import OssUploadField from '../components/OssUploadField';
|
import OssUploadField from '../components/OssUploadField';
|
||||||
import MultiOssUploadField from '../components/MultiOssUploadField';
|
|
||||||
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
|
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
|
||||||
import {
|
import {
|
||||||
canPartnerOpenStore,
|
canPartnerOpenStore,
|
||||||
@@ -43,8 +42,7 @@ export default function StoreDetailPage() {
|
|||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
loginPhone: '',
|
phone: '',
|
||||||
contactPhone: '',
|
|
||||||
address: '',
|
address: '',
|
||||||
intro: '',
|
intro: '',
|
||||||
benefitUsageRule: '',
|
benefitUsageRule: '',
|
||||||
@@ -65,8 +63,7 @@ export default function StoreDetailPage() {
|
|||||||
setStore(data);
|
setStore(data);
|
||||||
setForm({
|
setForm({
|
||||||
name: String(data.name || ''),
|
name: String(data.name || ''),
|
||||||
loginPhone: String(data.phone || ''),
|
phone: String(data.phone || ''),
|
||||||
contactPhone: String(data.contactPhone || data.phone || ''),
|
|
||||||
address: String(data.address || ''),
|
address: String(data.address || ''),
|
||||||
intro: String(data.intro || ''),
|
intro: String(data.intro || ''),
|
||||||
benefitUsageRule: (() => {
|
benefitUsageRule: (() => {
|
||||||
@@ -166,7 +163,7 @@ export default function StoreDetailPage() {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
contactPhone: form.contactPhone.trim(),
|
phone: form.phone.trim(),
|
||||||
address: form.address.trim(),
|
address: form.address.trim(),
|
||||||
intro: form.intro.trim(),
|
intro: form.intro.trim(),
|
||||||
benefitUsageRule: form.benefitUsageRule.trim() || null,
|
benefitUsageRule: form.benefitUsageRule.trim() || null,
|
||||||
@@ -348,31 +345,12 @@ export default function StoreDetailPage() {
|
|||||||
<label>门店名称</label>
|
<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 }} />
|
<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>
|
||||||
<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">
|
<div className="partner-field">
|
||||||
<label>联系电话</label>
|
<label>联系电话</label>
|
||||||
<div className="partner-field-input">
|
<div className="partner-field-input">
|
||||||
<span className="material-symbols-outlined">phone_in_talk</span>
|
<span className="material-symbols-outlined">call</span>
|
||||||
<input
|
<input disabled={readOnly} type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||||
disabled={readOnly}
|
|
||||||
type="tel"
|
|
||||||
placeholder="手机号或座机,如 0379-8888888"
|
|
||||||
value={form.contactPhone}
|
|
||||||
onChange={(e) => setForm({ ...form, contactPhone: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
|
||||||
店长或对外展示号码,用户端拨号使用此号码,支持座机。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>门店地址</label>
|
<label>门店地址</label>
|
||||||
@@ -431,13 +409,26 @@ export default function StoreDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{canMutate && !readOnly ? (
|
{canMutate && !readOnly ? (
|
||||||
<>
|
<>
|
||||||
<MultiOssUploadField
|
<div className="partner-upload-grid">
|
||||||
|
{envPhotoUrls.map((url, index) => (
|
||||||
|
<OssUploadField
|
||||||
|
key={index}
|
||||||
|
compact
|
||||||
bizType="STORE_ENV"
|
bizType="STORE_ENV"
|
||||||
maxCount={20}
|
mediaType="IMAGE"
|
||||||
value={uniqueEnvUrls(envPhotoUrls)}
|
value={url}
|
||||||
onChange={(urls) => setEnvPhotoUrls(urls.length ? urls : [''])}
|
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
|
||||||
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="partner-btn-outline"
|
className="partner-btn-outline"
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAcce
|
|||||||
import {
|
import {
|
||||||
canPartnerOpenStore,
|
canPartnerOpenStore,
|
||||||
storeAuditLabel,
|
storeAuditLabel,
|
||||||
storeListBadge,
|
storeAuditPillClass,
|
||||||
|
storeStatusLabel,
|
||||||
|
storeStatusPillClass,
|
||||||
type StoreStatusValue,
|
type StoreStatusValue,
|
||||||
} from '../lib/storeStatus';
|
} from '../lib/storeStatus';
|
||||||
import { usePartnerPageView } from '../lib/usePageView';
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
@@ -18,10 +20,10 @@ type StatusFilter = 'ALL' | StoreStatusValue | 'PENDING_AUDIT' | 'REJECTED';
|
|||||||
const FILTERS: { key: StatusFilter; label: string }[] = [
|
const FILTERS: { key: StatusFilter; label: string }[] = [
|
||||||
{ key: 'ALL', label: '全部' },
|
{ key: 'ALL', label: '全部' },
|
||||||
{ key: 'OPEN', label: '营业中' },
|
{ key: 'OPEN', label: '营业中' },
|
||||||
{ key: 'PAUSED', label: '临时闭店' },
|
{ key: 'PAUSED', label: '暂时闭店' },
|
||||||
{ key: 'PENDING_AUDIT', label: '待审核' },
|
{ key: 'PENDING_AUDIT', label: '待审核' },
|
||||||
{ key: 'REJECTED', label: '已驳回' },
|
{ key: 'REJECTED', label: '已驳回' },
|
||||||
{ key: 'CLOSED', label: '永久闭店' },
|
{ key: 'CLOSED', label: '关闭' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function StoreListPage() {
|
export default function StoreListPage() {
|
||||||
@@ -44,10 +46,7 @@ export default function StoreListPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||||
navigate('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void loadStores();
|
void loadStores();
|
||||||
}, [navigate, loadStores]);
|
}, [navigate, loadStores]);
|
||||||
|
|
||||||
@@ -55,9 +54,7 @@ export default function StoreListPage() {
|
|||||||
document.title = canMutate ? '门店管理' : '我的门店';
|
document.title = canMutate ? '门店管理' : '我的门店';
|
||||||
}, [canMutate]);
|
}, [canMutate]);
|
||||||
|
|
||||||
const filtered = useMemo(
|
const filtered = useMemo(() => stores.filter((s) => {
|
||||||
() =>
|
|
||||||
stores.filter((s) => {
|
|
||||||
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
|
||||||
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
|
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
|
||||||
const status = String(s.status).toUpperCase();
|
const status = String(s.status).toUpperCase();
|
||||||
@@ -66,17 +63,11 @@ export default function StoreListPage() {
|
|||||||
else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED';
|
else if (filter === 'REJECTED') matchStatus = audit === 'REJECTED';
|
||||||
else if (filter !== 'ALL') matchStatus = status === filter;
|
else if (filter !== 'ALL') matchStatus = status === filter;
|
||||||
return matchQ && matchStatus;
|
return matchQ && matchStatus;
|
||||||
}),
|
}), [stores, q, filter]);
|
||||||
[stores, q, filter],
|
|
||||||
);
|
|
||||||
|
|
||||||
async function updateStatus(storeId: string, next: StoreStatusValue, auditStatus?: string) {
|
async function updateStatus(storeId: string, next: StoreStatusValue, auditStatus?: string) {
|
||||||
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
|
if (next === 'OPEN' && !canPartnerOpenStore(auditStatus)) {
|
||||||
setError(
|
setError(auditStatus === 'REJECTED' ? '门店审核未通过,请查看驳回原因并修改后重新提交' : '门店尚在总部审核中,通过后方可开门');
|
||||||
auditStatus === 'REJECTED'
|
|
||||||
? '门店审核未通过,请查看驳回原因并修改后重新提交'
|
|
||||||
: '门店尚在总部审核中,通过后方可开门',
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (next === 'CLOSED') {
|
if (next === 'CLOSED') {
|
||||||
@@ -91,8 +82,7 @@ export default function StoreListPage() {
|
|||||||
body: JSON.stringify({ status: next }),
|
body: JSON.stringify({ status: next }),
|
||||||
});
|
});
|
||||||
await loadStores();
|
await loadStores();
|
||||||
if (next === 'OPEN') toastSuccess('已营业');
|
if (next === 'OPEN') toastSuccess('开店成功');
|
||||||
if (next === 'PAUSED') toastSuccess('已临时闭店');
|
|
||||||
} catch {
|
} catch {
|
||||||
/* request 已 toast */
|
/* request 已 toast */
|
||||||
} finally {
|
} finally {
|
||||||
@@ -112,7 +102,6 @@ export default function StoreListPage() {
|
|||||||
body: JSON.stringify({ status: 'CLOSED' }),
|
body: JSON.stringify({ status: 'CLOSED' }),
|
||||||
});
|
});
|
||||||
await loadStores();
|
await loadStores();
|
||||||
toastSuccess('已永久闭店');
|
|
||||||
} catch {
|
} catch {
|
||||||
/* request 已 toast */
|
/* request 已 toast */
|
||||||
} finally {
|
} finally {
|
||||||
@@ -122,33 +111,19 @@ export default function StoreListPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
|
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
|
||||||
{error && (
|
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
|
||||||
<p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="partner-sticky-filter">
|
<div className="partner-sticky-filter">
|
||||||
<div className="partner-search">
|
<div className="partner-search">
|
||||||
<span className="material-symbols-outlined">search</span>
|
<span className="material-symbols-outlined">search</span>
|
||||||
<input placeholder="搜索门店名称/地址" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input placeholder="搜索门店名称/地址" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-store-filter-row">
|
<div className="partner-chips">
|
||||||
<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) => (
|
{FILTERS.map((f) => (
|
||||||
<option key={f.key} value={f.key}>
|
<button key={f.key} type="button" className={`partner-chip${filter === f.key ? ' active' : ''}`} onClick={() => setFilter(f.key)}>
|
||||||
{f.label}
|
{f.label}
|
||||||
</option>
|
</button>
|
||||||
))}
|
))}
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -170,114 +145,87 @@ export default function StoreListPage() {
|
|||||||
const dim = currentStatus === 'CLOSED';
|
const dim = currentStatus === 'CLOSED';
|
||||||
const storeName = String(s.name || '未命名门店');
|
const storeName = String(s.name || '未命名门店');
|
||||||
const busy = updatingId === storeId;
|
const busy = updatingId === storeId;
|
||||||
const badge = storeListBadge(s);
|
const canOpen = canPartnerOpenStore(auditStatus);
|
||||||
const switchOn = currentStatus === 'OPEN';
|
|
||||||
const switchDisabled =
|
|
||||||
busy ||
|
|
||||||
currentStatus === 'CLOSED' ||
|
|
||||||
auditStatus === 'PENDING' ||
|
|
||||||
auditStatus === 'REJECTED';
|
|
||||||
return (
|
return (
|
||||||
<div key={storeId} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
|
<div key={storeId} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
|
||||||
<Link
|
<Link to={`/stores/${storeId}`} className="partner-store-card-hit" style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||||
to={`/stores/${storeId}`}
|
|
||||||
className="partner-store-card-hit"
|
|
||||||
style={{ color: 'inherit', textDecoration: 'none' }}
|
|
||||||
>
|
|
||||||
<div className="partner-store-card-header">
|
<div className="partner-store-card-header">
|
||||||
<div className="partner-store-card-main">
|
<div>
|
||||||
<p className="label-md text-muted" style={{ marginBottom: 2 }}>
|
<p className="label-md text-muted" style={{ marginBottom: 2 }}>门店名称</p>
|
||||||
门店名称
|
|
||||||
</p>
|
|
||||||
<h3 className="headline-md">{storeName}</h3>
|
<h3 className="headline-md">{storeName}</h3>
|
||||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>
|
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(s.address || s.district || '')}</p>
|
||||||
{String(s.address || s.district || '')}
|
{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)}` : ''}
|
||||||
</p>
|
</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>
|
||||||
<span className={`partner-status-pill partner-store-card-badge ${badge.pillClass}`}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
||||||
{badge.label}
|
{auditStatus !== 'APPROVED' ? (
|
||||||
|
<span className={`partner-status-pill ${storeAuditPillClass(auditStatus)}`}>
|
||||||
|
{storeAuditLabel(auditStatus)}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={`partner-status-pill ${storeStatusPillClass(currentStatus)}`}>
|
||||||
|
{storeStatusLabel(currentStatus)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
{canMutate ? (
|
{canMutate && (
|
||||||
<div className="partner-store-card-actions partner-store-card-actions--v3416">
|
<div className="partner-store-card-actions">
|
||||||
{currentStatus !== 'CLOSED' ? (
|
|
||||||
<>
|
|
||||||
<label
|
|
||||||
className={`partner-store-switch${switchDisabled ? ' partner-store-switch--disabled' : ''}`}
|
|
||||||
>
|
|
||||||
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="partner-store-close-btn"
|
className="btn btn-outline"
|
||||||
disabled={busy}
|
style={{ fontSize: 12, padding: '8px 12px' }}
|
||||||
|
disabled={busy || currentStatus === 'CLOSED' || currentStatus === 'PAUSED' || auditStatus === 'PENDING'}
|
||||||
|
onClick={() => void updateStatus(storeId, 'PAUSED', auditStatus)}
|
||||||
|
>
|
||||||
|
暂时闭店
|
||||||
|
</button>
|
||||||
|
<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'}
|
||||||
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
|
onClick={() => void updateStatus(storeId, 'CLOSED', auditStatus)}
|
||||||
>
|
>
|
||||||
永久闭店
|
关闭
|
||||||
</button>
|
</button>
|
||||||
</>
|
{currentStatus === 'PAUSED' && (
|
||||||
) : null}
|
<button
|
||||||
<Link
|
type="button"
|
||||||
to={`/stores/${storeId}`}
|
className="btn btn-outline"
|
||||||
className="partner-menu-icon"
|
style={{ fontSize: 12, padding: '8px 12px' }}
|
||||||
style={{ width: 40, height: 40, borderRadius: 8, textDecoration: 'none' }}
|
disabled={busy || !canOpen}
|
||||||
|
onClick={() => void updateStatus(storeId, 'OPEN', auditStatus)}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>
|
{canOpen ? '开门营业' : '待审核通过'}
|
||||||
edit
|
</button>
|
||||||
</span>
|
)}
|
||||||
|
<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>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{closeTarget && (
|
{closeTarget && (
|
||||||
<div
|
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseTarget(null)}>
|
||||||
className="partner-ship-modal-backdrop"
|
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||||
role="presentation"
|
<h3 className="headline-md" style={{ marginBottom: 8 }}>确认关闭门店?</h3>
|
||||||
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 className="body-md text-muted" style={{ lineHeight: 1.5 }}>
|
||||||
永久闭店后不可再开门营业,确认关闭该门店?
|
关闭后不可恢复营业,确认关闭该门店?
|
||||||
</p>
|
</p>
|
||||||
<div className="partner-ship-actions">
|
<div className="partner-ship-actions">
|
||||||
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
|
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
|
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
|
||||||
确认永久闭店
|
确认关闭
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import type { StorePackagesResponse } from '@dukang/shared-types';
|
import type { StorePackagesResponse } from '@dukang/shared-types';
|
||||||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
|
||||||
import StorePackagesForm from '../components/StorePackagesForm';
|
import StorePackagesForm from '../components/StorePackagesForm';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { toastError, toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
@@ -31,18 +30,7 @@ export default function StorePackagesPage() {
|
|||||||
: data.live?.length
|
: data.live?.length
|
||||||
? data.live
|
? data.live
|
||||||
: [emptyPackage()];
|
: [emptyPackage()];
|
||||||
setItems(
|
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
|
||||||
base.map((p, i) => {
|
|
||||||
const imageUrls = normalizeStorePackageImageUrls(p);
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
price: String(p.price),
|
|
||||||
imageUrl: imageUrls[0] ?? '',
|
|
||||||
imageUrls,
|
|
||||||
sortOrder: i,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
setPending(data.pendingRequest ?? null);
|
setPending(data.pendingRequest ?? null);
|
||||||
})
|
})
|
||||||
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'))
|
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'))
|
||||||
|
|||||||
@@ -1960,134 +1960,6 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
color: #fff;
|
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 {
|
.partner-store-card {
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
@@ -2189,39 +2061,6 @@ nav.app-tabbar .app-tabbar-label {
|
|||||||
margin: 0 var(--space-page) var(--space-md);
|
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 ── */
|
/* ── Store detail ── */
|
||||||
.partner-detail-page {
|
.partner-detail-page {
|
||||||
padding-bottom: 96px;
|
padding-bottom: 96px;
|
||||||
|
|||||||
@@ -13,8 +13,7 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"],
|
"@dukang/shared-ui/*": ["../../packages/shared-ui/src/*"]
|
||||||
"@dukang/domain": ["../../packages/domain/src/index.ts"]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
|
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
|
||||||
'@dukang/domain': path.resolve(__dirname, '../../packages/domain/src/index.ts'),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
|
||||||
port: 5175,
|
port: 5175,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
|
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import {
|
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
STORE_PACKAGE_IMAGE_MAX_COUNT,
|
|
||||||
STORE_PACKAGE_MAX_COUNT,
|
|
||||||
} from '@dukang/shared-types';
|
|
||||||
import { emptyPackage, type PackageFormItem } from '../lib/storePackages';
|
import { emptyPackage, type PackageFormItem } from '../lib/storePackages';
|
||||||
import { uploadFileToOss } from '../lib/upload';
|
import { uploadFileToOss } from '../lib/upload';
|
||||||
|
|
||||||
@@ -17,32 +14,14 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
||||||
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
async function pickPackageImages(pkgIndex: number, fileList?: FileList | null) {
|
async function pickPackageImage(index: number, file?: File | null) {
|
||||||
if (!fileList?.length || disabled) return;
|
if (!file || disabled) return;
|
||||||
const current = items[pkgIndex];
|
setUploadingIndex(index);
|
||||||
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 {
|
try {
|
||||||
const appended: string[] = [];
|
|
||||||
for (const file of files) {
|
|
||||||
const result = await uploadFileToOss(file, 'STORE_PACKAGE');
|
const result = await uploadFileToOss(file, 'STORE_PACKAGE');
|
||||||
appended.push(result.url);
|
updateAt(index, { imageUrl: result.url });
|
||||||
}
|
|
||||||
const next = [...existing, ...appended];
|
|
||||||
updateAt(pkgIndex, { imageUrls: next, imageUrl: next[0] ?? '' });
|
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingIndex(null);
|
setUploadingIndex(null);
|
||||||
const input = fileRefs.current[pkgIndex];
|
|
||||||
if (input) input.value = '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,12 +58,6 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
{list.map((item, index) => {
|
{list.map((item, index) => {
|
||||||
const isCollapsed = !!collapsed[index];
|
const isCollapsed = !!collapsed[index];
|
||||||
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
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 (
|
return (
|
||||||
<section key={index} className={`shop-packages-card${isCollapsed ? ' shop-packages-card--collapsed' : ''}`}>
|
<section key={index} className={`shop-packages-card${isCollapsed ? ' shop-packages-card--collapsed' : ''}`}>
|
||||||
<div className="shop-packages-card-head">
|
<div className="shop-packages-card-head">
|
||||||
@@ -110,7 +83,7 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
{!isCollapsed ? (
|
{!isCollapsed ? (
|
||||||
<>
|
<>
|
||||||
<label className="shop-packages-field">
|
<label className="shop-packages-field">
|
||||||
<span className="shop-packages-label">套餐名称</span>
|
<span className="shop-packages-label">套餐名称 *</span>
|
||||||
<input
|
<input
|
||||||
className="shop-packages-input"
|
className="shop-packages-input"
|
||||||
placeholder="如:套餐A"
|
placeholder="如:套餐A"
|
||||||
@@ -121,12 +94,12 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="shop-packages-field">
|
<label className="shop-packages-field">
|
||||||
<span className="shop-packages-label">价格(元)</span>
|
<span className="shop-packages-label">价格(元) *</span>
|
||||||
<input
|
<input
|
||||||
className="shop-packages-input"
|
className="shop-packages-input"
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
step="0.01"
|
step={0.01}
|
||||||
placeholder="198"
|
placeholder="198"
|
||||||
value={item.price}
|
value={item.price}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -135,10 +108,10 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="shop-packages-field">
|
<label className="shop-packages-field">
|
||||||
<span className="shop-packages-label">菜品</span>
|
<span className="shop-packages-label">菜品 *</span>
|
||||||
<textarea
|
<textarea
|
||||||
className="shop-packages-input"
|
className="shop-packages-textarea"
|
||||||
rows={2}
|
rows={3}
|
||||||
placeholder="红烧肉、红烧鱼、油焖茄子"
|
placeholder="红烧肉、红烧鱼、油焖茄子"
|
||||||
value={item.dishes}
|
value={item.dishes}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -148,9 +121,8 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
|
|
||||||
<label className="shop-packages-field">
|
<label className="shop-packages-field">
|
||||||
<span className="shop-packages-label">使用时间</span>
|
<span className="shop-packages-label">使用时间</span>
|
||||||
<textarea
|
<input
|
||||||
className="shop-packages-input"
|
className="shop-packages-input"
|
||||||
rows={2}
|
|
||||||
placeholder="节假日除外"
|
placeholder="节假日除外"
|
||||||
value={item.usableTime || ''}
|
value={item.usableTime || ''}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -158,35 +130,14 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="shop-packages-field">
|
<label className="shop-packages-field">
|
||||||
<span className="shop-packages-label">
|
<span className="shop-packages-label">套餐图片</span>
|
||||||
套餐图片(最多 {STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量)
|
{item.imageUrl ? (
|
||||||
</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
|
<img
|
||||||
src={url}
|
src={item.imageUrl}
|
||||||
alt=""
|
alt=""
|
||||||
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
|
style={{ width: '100%', maxHeight: 160, objectFit: 'cover', borderRadius: 8, marginBottom: 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}
|
) : null}
|
||||||
<input
|
<input
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
@@ -194,29 +145,25 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
|||||||
}}
|
}}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
multiple
|
|
||||||
hidden
|
hidden
|
||||||
disabled={disabled || filled.length >= STORE_PACKAGE_IMAGE_MAX_COUNT}
|
disabled={disabled}
|
||||||
onChange={(e) => void pickPackageImages(index, e.target.files)}
|
onChange={(e) => void pickPackageImage(index, e.target.files?.[0])}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shop-packages-add"
|
className="shop-packages-add"
|
||||||
style={{ marginTop: 0 }}
|
style={{ marginTop: 0 }}
|
||||||
disabled={disabled || uploadingIndex === index || filled.length >= STORE_PACKAGE_IMAGE_MAX_COUNT}
|
disabled={disabled || uploadingIndex === index}
|
||||||
onClick={() => fileRefs.current[index]?.click()}
|
onClick={() => fileRefs.current[index]?.click()}
|
||||||
>
|
>
|
||||||
{uploadingIndex === index
|
{uploadingIndex === index ? '上传中…' : item.imageUrl ? '更换图片' : '上传图片'}
|
||||||
? '上传中…'
|
|
||||||
: `批量上传(${filled.length}/${STORE_PACKAGE_IMAGE_MAX_COUNT})`}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<label className="shop-packages-field">
|
<label className="shop-packages-field">
|
||||||
<span className="shop-packages-label">其他说明</span>
|
<span className="shop-packages-label">其他说明</span>
|
||||||
<textarea
|
<input
|
||||||
className="shop-packages-input"
|
className="shop-packages-input"
|
||||||
rows={2}
|
|
||||||
placeholder="不可叠加"
|
placeholder="不可叠加"
|
||||||
value={item.otherNotes || ''}
|
value={item.otherNotes || ''}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ type WechatScanAuthModalProps = {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
/** bind=首次绑定;recover=扫码 JSSDK 失败后的恢复引导 */
|
|
||||||
mode?: 'bind' | 'recover';
|
|
||||||
onAuthorize: () => void;
|
onAuthorize: () => void;
|
||||||
onRefresh?: () => void;
|
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -13,43 +10,28 @@ export default function WechatScanAuthModal({
|
|||||||
open,
|
open,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
mode = 'bind',
|
|
||||||
onAuthorize,
|
onAuthorize,
|
||||||
onRefresh,
|
|
||||||
onCancel,
|
onCancel,
|
||||||
}: WechatScanAuthModalProps) {
|
}: WechatScanAuthModalProps) {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const isRecover = mode === 'recover';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-scan-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="shop-scan-auth-title">
|
<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-card">
|
||||||
<div className="shop-scan-auth-icon">
|
<div className="shop-scan-auth-icon">
|
||||||
<span className="material-symbols-outlined shop-fill-icon">
|
<span className="material-symbols-outlined shop-fill-icon">qr_code_scanner</span>
|
||||||
{isRecover ? 'sync_problem' : 'qr_code_scanner'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title">
|
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title">微信授权</h2>
|
||||||
{isRecover ? '扫码能力未就绪' : '微信授权'}
|
|
||||||
</h2>
|
|
||||||
<p className="shop-scan-auth-desc">
|
<p className="shop-scan-auth-desc">
|
||||||
{isRecover
|
扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。
|
||||||
? '微信扫码接口校验失败(常见于 iPhone 登录/授权后)。请先刷新页面;仍失败再重新授权微信。'
|
|
||||||
: '扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。'}
|
|
||||||
</p>
|
</p>
|
||||||
{error && <p className="shop-scan-auth-error" role="alert">{error}</p>}
|
{error && <p className="shop-scan-auth-error" role="alert">{error}</p>}
|
||||||
<div className="shop-scan-auth-actions">
|
<div className="shop-scan-auth-actions">
|
||||||
<button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}>
|
<button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}>
|
||||||
取消
|
取消
|
||||||
</button>
|
</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}>
|
<button type="button" className="shop-scan-auth-confirm" onClick={onAuthorize} disabled={loading}>
|
||||||
{loading ? '跳转授权中…' : isRecover ? '重新授权微信' : '微信授权'}
|
{loading ? '跳转授权中…' : '微信授权'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import { isIosDevice } from '@dukang/weixin-sdk';
|
|||||||
/** 扫码前发起 OAuth 时标记,回跳后在首页续扫 */
|
/** 扫码前发起 OAuth 时标记,回跳后在首页续扫 */
|
||||||
export const SHOP_PENDING_SCAN_KEY = 'shop_pending_scan';
|
export const SHOP_PENDING_SCAN_KEY = 'shop_pending_scan';
|
||||||
|
|
||||||
/** 短信登录后绑定微信等 OAuth 回跳:下次手动扫码加长预热(不自动打开相机) */
|
|
||||||
export const SHOP_SCAN_WARMUP_KEY = 'shop_scan_warmup';
|
|
||||||
|
|
||||||
export function markPendingScanAfterAuth(): void {
|
export function markPendingScanAfterAuth(): void {
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(SHOP_PENDING_SCAN_KEY, '1');
|
sessionStorage.setItem(SHOP_PENDING_SCAN_KEY, '1');
|
||||||
@@ -30,25 +27,6 @@ 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 回跳后延迟再调 scanQRCode(iOS JSSDK 离线校验更慢) */
|
/** OAuth 回跳后延迟再调 scanQRCode(iOS JSSDK 离线校验更慢) */
|
||||||
export function getPostAuthScanDelayMs(): number {
|
export function getPostAuthScanDelayMs(): number {
|
||||||
return isIosDevice() ? 1200 : 600;
|
return isIosDevice() ? 1200 : 600;
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||||
import {
|
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
STORE_PACKAGE_IMAGE_MAX_COUNT,
|
|
||||||
STORE_PACKAGE_MAX_COUNT,
|
|
||||||
normalizeStorePackageImageUrls,
|
|
||||||
} from '@dukang/shared-types';
|
|
||||||
|
|
||||||
export type PackageFormItem = StorePackageItemDto;
|
export type PackageFormItem = StorePackageItemDto;
|
||||||
|
|
||||||
@@ -15,34 +11,24 @@ export function emptyPackage(index = 0): PackageFormItem {
|
|||||||
usableTime: '',
|
usableTime: '',
|
||||||
otherNotes: '',
|
otherNotes: '',
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
imageUrls: [],
|
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
|
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
|
||||||
return raw
|
return raw
|
||||||
.map((item, index) => {
|
.map((item, index) => ({
|
||||||
const imageUrls = normalizeStorePackageImageUrls(item);
|
|
||||||
return {
|
|
||||||
name: item.name.trim(),
|
name: item.name.trim(),
|
||||||
price: item.price.trim(),
|
price: item.price.trim(),
|
||||||
dishes: item.dishes.trim(),
|
dishes: item.dishes.trim(),
|
||||||
usableTime: item.usableTime?.trim() || '',
|
usableTime: item.usableTime?.trim() || '',
|
||||||
otherNotes: item.otherNotes?.trim() || '',
|
otherNotes: item.otherNotes?.trim() || '',
|
||||||
imageUrl: imageUrls[0] ?? '',
|
imageUrl: item.imageUrl?.trim() || '',
|
||||||
imageUrls,
|
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
};
|
}))
|
||||||
})
|
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.name ||
|
item.name || item.price || item.dishes || item.usableTime || item.otherNotes || item.imageUrl,
|
||||||
item.price ||
|
|
||||||
item.dishes ||
|
|
||||||
item.usableTime ||
|
|
||||||
item.otherNotes ||
|
|
||||||
item.imageUrls.length > 0,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,15 +43,6 @@ export function validatePackageFormItems(items: PackageFormItem[]): string | nul
|
|||||||
const price = Number(item.price);
|
const price = Number(item.price);
|
||||||
if (!Number.isFinite(price) || price < 0) return `第 ${i + 1} 条套餐价格须为非负数字`;
|
if (!Number.isFinite(price) || price < 0) return `第 ${i + 1} 条套餐价格须为非负数字`;
|
||||||
if (!item.dishes) 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;
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-type
|
|||||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||||
import { isWechatEnv, weixinSdk } from './weixin';
|
import { isWechatEnv, weixinSdk } from './weixin';
|
||||||
import { markScanWarmupAfterAuth } from './shop-scan-auth';
|
|
||||||
import { request, saveWechatSession, type ShopSessionPayload } from './api';
|
import { request, saveWechatSession, type ShopSessionPayload } from './api';
|
||||||
|
|
||||||
export type ShopAccountProfile = {
|
export type ShopAccountProfile = {
|
||||||
@@ -163,19 +162,15 @@ export async function loginShopWithWechat(): Promise<ShopSessionPayload | null |
|
|||||||
if (!isWechatEnv()) {
|
if (!isWechatEnv()) {
|
||||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||||
}
|
}
|
||||||
markScanWarmupAfterAuth();
|
|
||||||
const result = await weixinSdk.login();
|
const result = await weixinSdk.login();
|
||||||
if (result) return handleShopWechatLoginResult(result);
|
if (result) return handleShopWechatLoginResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
|
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
|
||||||
export async function bindShopWechatAfterSmsLogin(session?: ShopSessionPayload): Promise<'skipped' | void> {
|
export async function bindShopWechatAfterSmsLogin(): Promise<void> {
|
||||||
const config = await fetchClientConfig();
|
const config = await fetchClientConfig();
|
||||||
if (!isWxAuthorizeEnabled(config)) return 'skipped';
|
if (!isWxAuthorizeEnabled(config)) return;
|
||||||
if (!isWechatEnv()) return 'skipped';
|
if (!isWechatEnv()) return;
|
||||||
// 已绑定则勿再 OAuth:每次 OAuth 回跳都会重置 iOS JSSDK 入场 URL,易导致扫码失败
|
|
||||||
if (session?.account?.hasWechat) return 'skipped';
|
|
||||||
markScanWarmupAfterAuth();
|
|
||||||
await weixinSdk.login();
|
await weixinSdk.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +180,5 @@ export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
|
|||||||
if (!isWechatEnv()) {
|
if (!isWechatEnv()) {
|
||||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||||
}
|
}
|
||||||
markScanWarmupAfterAuth();
|
|
||||||
return weixinSdk.login();
|
return weixinSdk.login();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,624 +1,293 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { isIosDevice, isScanPermissionWarmupError } from '@dukang/weixin-sdk';
|
|
||||||
|
|
||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { isScanPermissionWarmupError } from '@dukang/weixin-sdk';
|
import { isScanPermissionWarmupError } from '@dukang/weixin-sdk';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
||||||
consumeScanWarmupAfterAuth,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
authorizeShopWechat,
|
authorizeShopWechat,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
checkNeedsWechatAuth,
|
checkNeedsWechatAuth,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fetchShopAccount,
|
fetchShopAccount,
|
||||||
|
|
||||||
|
|
||||||
} from '../lib/wechat-auth';
|
} from '../lib/wechat-auth';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
clearPendingScanAfterAuth,
|
clearPendingScanAfterAuth,
|
||||||
|
|
||||||
if (/invalid signature|config:fail|signature/i.test(msg)) {
|
getPostAuthScanDelayMs,
|
||||||
|
|
||||||
return '微信扫码签名校验失败,请刷新页面或重新授权微信后重试';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
markPendingScanAfterAuth,
|
markPendingScanAfterAuth,
|
||||||
|
|
||||||
|
|
||||||
|
peekPendingScanAfterAuth,
|
||||||
return '微信授权后扫码仍未就绪,请刷新页面或重新授权微信';
|
|
||||||
|
|
||||||
|
|
||||||
|
} from '../lib/shop-scan-auth';
|
||||||
return '微信扫码能力未就绪,请刷新页面或重新授权微信';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
||||||
|
|
||||||
|
|
||||||
import { useStorePageView } from '../lib/usePageView';
|
import { useStorePageView } from '../lib/usePageView';
|
||||||
function isScanRecoverableError(msg: string): boolean {
|
|
||||||
|
|
||||||
return isScanPermissionWarmupError(msg) || /签名校验失败|扫码能力未就绪|请刷新页面/i.test(msg);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { trackStore } from '../lib/analytics';
|
import { trackStore } from '../lib/analytics';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function formatMoney(n: number) {
|
function formatMoney(n: number) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const [authModalMode, setAuthModalMode] = useState<'bind' | 'recover'>('bind');
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
|
function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (/invalid signature/i.test(msg)) {
|
if (/invalid signature/i.test(msg)) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
|
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (isScanPermissionWarmupError(msg)) {
|
if (isScanPermissionWarmupError(msg)) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (opts?.afterAuth) {
|
if (opts?.afterAuth) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return '微信授权已完成,扫码权限仍在准备中,请再点一次「扫码核销」';
|
return '微信授权已完成,扫码权限仍在准备中,请再点一次「扫码核销」';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
|
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return msg;
|
return msg;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useStorePageView('store_home_view');
|
useStorePageView('store_home_view');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { ready, authenticated } = useStoreSession();
|
const { ready, authenticated } = useStoreSession();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const [scanMsg, setScanMsg] = useState('');
|
const [scanMsg, setScanMsg] = useState('');
|
||||||
|
|
||||||
|
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
// iOS / OAuth 回跳后须重新 wx.config(签名用入场 URL)
|
|
||||||
if (opts?.postAuthWarmup || isIosDevice()) {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const [authLoading, setAuthLoading] = useState(false);
|
const [authLoading, setAuthLoading] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const [authError, setAuthError] = useState('');
|
const [authError, setAuthError] = useState('');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const pendingScanStartedRef = useRef(false);
|
const pendingScanStartedRef = useRef(false);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const loadDashboard = useCallback(() => {
|
const loadDashboard = useCallback(() => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
|
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
|
|
||||||
|
|
||||||
|
setDash(d);
|
||||||
const tip = formatScanError(e, { afterAuth: opts?.postAuthWarmup });
|
|
||||||
|
|
||||||
setScanMsg(tip);
|
|
||||||
|
|
||||||
if (isScanRecoverableError(tip)) {
|
|
||||||
|
|
||||||
setAuthModalMode('recover');
|
|
||||||
|
|
||||||
setAuthError(tip);
|
|
||||||
|
|
||||||
setAuthModalOpen(true);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
void loadDashboard();
|
void loadDashboard();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}, [loadDashboard]);
|
}, [loadDashboard]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
|
|
||||||
function onResume() {
|
function onResume() {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setScanning(false);
|
setScanning(false);
|
||||||
|
|
||||||
|
|
||||||
void loadDashboard();
|
void loadDashboard();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function onVisibility() {
|
function onVisibility() {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (document.visibilityState === 'visible') onResume();
|
if (document.visibilityState === 'visible') onResume();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', onVisibility);
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
window.addEventListener('pageshow', onResume);
|
window.addEventListener('pageshow', onResume);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setAuthModalMode('bind');
|
|
||||||
|
|
||||||
setAuthError('');
|
|
||||||
|
|
||||||
window.addEventListener('focus', onResume);
|
window.addEventListener('focus', onResume);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
||||||
const needWarmup = consumeScanWarmupAfterAuth();
|
|
||||||
|
|
||||||
await runScan(needWarmup ? { postAuthWarmup: true } : undefined);
|
|
||||||
|
|
||||||
document.removeEventListener('visibilitychange', onVisibility);
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
window.removeEventListener('pageshow', onResume);
|
window.removeEventListener('pageshow', onResume);
|
||||||
|
|
||||||
|
|
||||||
window.removeEventListener('focus', onResume);
|
window.removeEventListener('focus', onResume);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}, [loadDashboard]);
|
}, [loadDashboard]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const runScan = useCallback(
|
const runScan = useCallback(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async (opts?: { postAuthWarmup?: boolean }) => {
|
async (opts?: { postAuthWarmup?: boolean }) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
trackStore('store_redeem_scan_start');
|
trackStore('store_redeem_scan_start');
|
||||||
|
|
||||||
|
|
||||||
if (!isWechatEnv()) {
|
if (!isWechatEnv()) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!opts?.postAuthWarmup) {
|
if (!opts?.postAuthWarmup) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setScanMsg('');
|
setScanMsg('');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (opts?.postAuthWarmup) {
|
if (opts?.postAuthWarmup) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
weixinSdk.reset();
|
weixinSdk.reset();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
await weixinSdk.init();
|
await weixinSdk.init();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const raw = await weixinSdk.scanQrCode(
|
const raw = await weixinSdk.scanQrCode(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
|
opts?.postAuthWarmup ? { postAuthWarmup: true } : undefined,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
void loadDashboard();
|
void loadDashboard();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const token = parseRedeemTokenFromScan(raw);
|
const token = parseRedeemTokenFromScan(raw);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
|
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setScanMsg(formatScanError(e, { afterAuth: opts?.postAuthWarmup }));
|
setScanMsg(formatScanError(e, { afterAuth: opts?.postAuthWarmup }));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
|
||||||
|
|
||||||
setScanning(false);
|
setScanning(false);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[loadDashboard, navigate],
|
[loadDashboard, navigate],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// OAuth 由 StoreSessionContext 单例处理;此处仅在授权完成后续扫
|
// OAuth 由 StoreSessionContext 单例处理;此处仅在授权完成后续扫
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!ready || !authenticated || !isWechatEnv()) return;
|
if (!ready || !authenticated || !isWechatEnv()) return;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (searchParams.get('code')) return;
|
if (searchParams.get('code')) return;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!peekPendingScanAfterAuth() || pendingScanStartedRef.current) return;
|
if (!peekPendingScanAfterAuth() || pendingScanStartedRef.current) return;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
pendingScanStartedRef.current = true;
|
pendingScanStartedRef.current = true;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
clearPendingScanAfterAuth();
|
clearPendingScanAfterAuth();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setAuthModalOpen(false);
|
setAuthModalOpen(false);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setAuthLoading(false);
|
setAuthLoading(false);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setAuthError('');
|
setAuthError('');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setScanMsg('微信授权成功,正在准备扫码…');
|
setScanMsg('微信授权成功,正在准备扫码…');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
void runScan({ postAuthWarmup: true });
|
void runScan({ postAuthWarmup: true });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}, getPostAuthScanDelayMs());
|
}, getPostAuthScanDelayMs());
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
|
|
||||||
mode={authModalMode}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}, [ready, authenticated, searchParams, runScan]);
|
}, [ready, authenticated, searchParams, runScan]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
onRefresh={() => {
|
|
||||||
|
|
||||||
window.location.reload();
|
|
||||||
|
|
||||||
}}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setAuthModalMode('bind');
|
|
||||||
|
|
||||||
async function handleScan() {
|
async function handleScan() {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setScanMsg('');
|
setScanMsg('');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!isWechatEnv()) {
|
if (!isWechatEnv()) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||||
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|||||||
@@ -159,9 +159,9 @@ export default function LoginPage() {
|
|||||||
});
|
});
|
||||||
saveRememberedSession(data);
|
saveRememberedSession(data);
|
||||||
applySession(data);
|
applySession(data);
|
||||||
if (isWechatEnv() && wxAuthorize && !data.account?.hasWechat) {
|
if (isWechatEnv() && wxAuthorize) {
|
||||||
setMsg('登录成功,正在关联微信…');
|
setMsg('登录成功,正在关联微信…');
|
||||||
await bindShopWechatAfterSmsLogin(data);
|
await bindShopWechatAfterSmsLogin();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
routeAfterShopLogin(data, navigate);
|
routeAfterShopLogin(data, navigate);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { StorePackagesResponse } from '@dukang/shared-types';
|
import type { StorePackagesResponse } from '@dukang/shared-types';
|
||||||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
|
||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import ShopPackagesForm from '../components/ShopPackagesForm';
|
import ShopPackagesForm from '../components/ShopPackagesForm';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
@@ -31,18 +30,7 @@ export default function PackagesPage() {
|
|||||||
: data.live?.length
|
: data.live?.length
|
||||||
? data.live
|
? data.live
|
||||||
: [emptyPackage()];
|
: [emptyPackage()];
|
||||||
setItems(
|
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
|
||||||
base.map((p, i) => {
|
|
||||||
const imageUrls = normalizeStorePackageImageUrls(p);
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
price: String(p.price),
|
|
||||||
imageUrl: imageUrls[0] ?? '',
|
|
||||||
imageUrls,
|
|
||||||
sortOrder: i,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
setPending(data.pendingRequest ?? null);
|
setPending(data.pendingRequest ?? null);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
|
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
import {
|
import {
|
||||||
needsStoreSelection,
|
needsStoreSelection,
|
||||||
@@ -11,14 +10,6 @@ import {
|
|||||||
type ShopStoreOption,
|
type ShopStoreOption,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
|
|
||||||
function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) {
|
|
||||||
if (shouldHardNavigateForJssdk()) {
|
|
||||||
hardNavigateInWechat('/');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate('/', { replace: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SelectStorePage() {
|
export default function SelectStorePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { applySession, store, authenticated } = useStoreSession();
|
const { applySession, store, authenticated } = useStoreSession();
|
||||||
@@ -46,7 +37,7 @@ export default function SelectStorePage() {
|
|||||||
async function onSelect(storeId: string) {
|
async function onSelect(storeId: string) {
|
||||||
if (loadingId) return;
|
if (loadingId) return;
|
||||||
if (storeId === currentStoreId) {
|
if (storeId === currentStoreId) {
|
||||||
goShopHome(navigate);
|
navigate('/', { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoadingId(storeId);
|
setLoadingId(storeId);
|
||||||
@@ -54,7 +45,7 @@ export default function SelectStorePage() {
|
|||||||
try {
|
try {
|
||||||
const session = await selectStore(storeId);
|
const session = await selectStore(storeId);
|
||||||
applySession(session);
|
applySession(session);
|
||||||
goShopHome(navigate);
|
navigate('/', { replace: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '选店失败');
|
setMsg(e instanceof Error ? e.message : '选店失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -175,11 +166,9 @@ export function routeAfterShopLogin(
|
|||||||
session: ShopSessionPayload,
|
session: ShopSessionPayload,
|
||||||
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
||||||
) {
|
) {
|
||||||
const path = needsStoreSelection(session) ? '/select-store' : '/';
|
if (needsStoreSelection(session)) {
|
||||||
// iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂
|
navigate('/select-store', { replace: true });
|
||||||
if (shouldHardNavigateForJssdk()) {
|
|
||||||
hardNavigateInWechat(path);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate(path, { replace: true });
|
navigate('/', { replace: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -794,7 +794,6 @@
|
|||||||
|
|
||||||
.shop-scan-auth-actions {
|
.shop-scan-auth-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
|
||||||
port: 5174,
|
port: 5174,
|
||||||
proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010' },
|
proxy: { '/api': 'http://localhost:3000' },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
|
||||||
import { track } from '../lib/analytics';
|
import { track } from '../lib/analytics';
|
||||||
import {
|
import { openWecomCustomerService } from '../lib/customer-service';
|
||||||
getCustomerServicePhone,
|
|
||||||
loadCustomerServicePhone,
|
|
||||||
openWecomCustomerService,
|
|
||||||
} from '../lib/customer-service';
|
|
||||||
|
|
||||||
type ContactCustomerSheetProps = {
|
type ContactCustomerSheetProps = {
|
||||||
orderId?: string;
|
orderId?: string;
|
||||||
@@ -13,13 +9,7 @@ type ContactCustomerSheetProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) {
|
export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) {
|
||||||
const [phone, setPhone] = useState(getCustomerServicePhone);
|
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadCustomerServicePhone().then(setPhone);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const tel = phone.replace(/-/g, '');
|
|
||||||
|
|
||||||
function openPhone() {
|
function openPhone() {
|
||||||
track('cs_contact', { type: 'phone', orderId });
|
track('cs_contact', { type: 'phone', orderId });
|
||||||
@@ -51,7 +41,7 @@ export default function ContactCustomerSheet({ orderId, onClose }: ContactCustom
|
|||||||
</div>
|
</div>
|
||||||
<div className="contact-customer-option-body">
|
<div className="contact-customer-option-body">
|
||||||
<p className="contact-customer-option-title">拨打总部客服电话</p>
|
<p className="contact-customer-option-title">拨打总部客服电话</p>
|
||||||
<p className="contact-customer-option-sub">{phone}</p>
|
<p className="contact-customer-option-sub">{CUSTOMER_SERVICE_PHONE}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
|
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,31 +1,12 @@
|
|||||||
import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types';
|
import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types';
|
||||||
import { fetchClientConfig } from './pay-wechat';
|
|
||||||
import { isWechatEnv } from './weixin';
|
import { isWechatEnv } from './weixin';
|
||||||
|
|
||||||
let cachedPhone = CUSTOMER_SERVICE_PHONE;
|
|
||||||
|
|
||||||
/** 企微客服链接:优先 Vite env,否则 shared-types 默认 */
|
/** 企微客服链接:优先 Vite env,否则 shared-types 默认 */
|
||||||
export function getCustomerServiceWecomUrl(): string {
|
export function getCustomerServiceWecomUrl(): string {
|
||||||
const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim();
|
const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim();
|
||||||
return fromEnv || CUSTOMER_SERVICE_WECOM_URL;
|
return fromEnv || CUSTOMER_SERVICE_WECOM_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCustomerServicePhone(): string {
|
|
||||||
return cachedPhone;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从系统设置拉取客服电话(失败则保持默认常量) */
|
|
||||||
export async function loadCustomerServicePhone(): Promise<string> {
|
|
||||||
try {
|
|
||||||
const cfg = await fetchClientConfig();
|
|
||||||
const phone = cfg.customerServicePhone?.trim();
|
|
||||||
if (phone) cachedPhone = phone;
|
|
||||||
} catch {
|
|
||||||
/* keep fallback */
|
|
||||||
}
|
|
||||||
return cachedPhone;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开企业微信客服会话(须在微信内;需用户点击手势)。
|
* 打开企业微信客服会话(须在微信内;需用户点击手势)。
|
||||||
* @returns true 已跳转;false 非微信环境已提示
|
* @returns true 已跳转;false 非微信环境已提示
|
||||||
@@ -39,5 +20,4 @@ export function openWecomCustomerService(): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated 请用 getCustomerServicePhone(),保留兼容旧引用 */
|
|
||||||
export { CUSTOMER_SERVICE_PHONE };
|
export { CUSTOMER_SERVICE_PHONE };
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import SubPageHeader from '../components/SubPageHeader';
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
import {
|
import { CUSTOMER_SERVICE_PHONE, openWecomCustomerService } from '../lib/customer-service';
|
||||||
getCustomerServicePhone,
|
|
||||||
loadCustomerServicePhone,
|
|
||||||
openWecomCustomerService,
|
|
||||||
} from '../lib/customer-service';
|
|
||||||
import { track } from '../lib/analytics';
|
import { track } from '../lib/analytics';
|
||||||
|
|
||||||
export default function CustomerServicePage() {
|
export default function CustomerServicePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [phone, setPhone] = useState(getCustomerServicePhone);
|
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadCustomerServicePhone().then(setPhone);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const tel = phone.replace(/-/g, '');
|
|
||||||
|
|
||||||
function openOnline() {
|
function openOnline() {
|
||||||
track('cs_contact', { type: 'wecom_kf' });
|
track('cs_contact', { type: 'wecom_kf' });
|
||||||
@@ -38,7 +27,7 @@ export default function CustomerServicePage() {
|
|||||||
|
|
||||||
<a className="customer-service-phone-link" href={`tel:${tel}`}>
|
<a className="customer-service-phone-link" href={`tel:${tel}`}>
|
||||||
<span className="material-symbols-outlined">call</span>
|
<span className="material-symbols-outlined">call</span>
|
||||||
或拨打客服电话 {phone}
|
或拨打客服电话 {CUSTOMER_SERVICE_PHONE}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
|
'/api': 'http://localhost:3000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -16,7 +16,7 @@ export default defineConfig(async () => ({
|
|||||||
plugins: ['@tarojs/plugin-html'],
|
plugins: ['@tarojs/plugin-html'],
|
||||||
defineConstants: {
|
defineConstants: {
|
||||||
/** H5 静态托管无 /api 代理时直连后端;dev 构建可通过 VITE_API_TARGET 覆盖 */
|
/** H5 静态托管无 /api 代理时直连后端;dev 构建可通过 VITE_API_TARGET 覆盖 */
|
||||||
TARO_APP_API_ORIGIN: JSON.stringify(process.env.VITE_API_TARGET ?? 'http://localhost:3010'),
|
TARO_APP_API_ORIGIN: JSON.stringify(process.env.VITE_API_TARGET ?? 'http://localhost:3000'),
|
||||||
},
|
},
|
||||||
copy: {
|
copy: {
|
||||||
patterns: [],
|
patterns: [],
|
||||||
@@ -44,7 +44,7 @@ export default defineConfig(async () => ({
|
|||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: process.env.VITE_API_TARGET ?? 'http://localhost:3010',
|
target: process.env.VITE_API_TARGET ?? 'http://localhost:3000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ function resolveApiBase(): string {
|
|||||||
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
|
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
|
||||||
? TARO_APP_API_ORIGIN
|
? TARO_APP_API_ORIGIN
|
||||||
: process.env.TARO_ENV === 'h5'
|
: process.env.TARO_ENV === 'h5'
|
||||||
? 'http://localhost:3010'
|
? 'http://localhost:3000'
|
||||||
: '';
|
: '';
|
||||||
if (origin) {
|
if (origin) {
|
||||||
return `${origin.replace(/\/$/, '')}/api/v1`;
|
return `${origin.replace(/\/$/, '')}/api/v1`;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pnpm build:mini-user:weapp
|
|||||||
|
|
||||||
| 环节 | 文件 | 说明 |
|
| 环节 | 文件 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3010`;`build:weapp`(`--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 |
|
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3000`;`build:weapp`(`--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 |
|
||||||
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
||||||
|
|
||||||
### 微信登录 `invalid code`
|
### 微信登录 `invalid code`
|
||||||
@@ -33,7 +33,7 @@ pnpm build:mini-user:weapp
|
|||||||
| 小程序 appid | `project.config.json` → `wxda31c8e8e85051e7` |
|
| 小程序 appid | `project.config.json` → `wxda31c8e8e85051e7` |
|
||||||
| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) |
|
| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) |
|
||||||
|
|
||||||
**本地联调(不接真实微信)**:保持默认即可(API → `localhost:3010`),并开启 Mock:
|
**本地联调(不接真实微信)**:保持默认即可(API → `localhost:3000`),并开启 Mock:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 终端 1
|
# 终端 1
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const isDevMode =
|
|||||||
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
|
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
|
||||||
const API_ORIGIN =
|
const API_ORIGIN =
|
||||||
process.env.VITE_API_TARGET ??
|
process.env.VITE_API_TARGET ??
|
||||||
(isDevMode ? 'http://localhost:3010' : 'https://api.dukanghaoke.com');
|
(isDevMode ? 'http://localhost:3000' : 'https://api.dukanghaoke.com');
|
||||||
|
|
||||||
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
|
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/mini-user",
|
"name": "@dukang/mini-user",
|
||||||
"version": "3.4.15",
|
"version": "3.4.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export default defineAppConfig({
|
|||||||
'pages/mine/index',
|
'pages/mine/index',
|
||||||
'pages/product-detail/index',
|
'pages/product-detail/index',
|
||||||
'pages/store-detail/index',
|
'pages/store-detail/index',
|
||||||
'pages/store-package-detail/index',
|
|
||||||
'pages/order-confirm/index',
|
'pages/order-confirm/index',
|
||||||
'pages/order-confirm-pickup/index',
|
'pages/order-confirm-pickup/index',
|
||||||
'pages/pay/index',
|
'pages/pay/index',
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ import WechatShareBootstrap from './components/WechatShareBootstrap';
|
|||||||
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
|
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
|
||||||
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
|
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
|
||||||
import { installClientErrorReporting } from './lib/client-error';
|
import { installClientErrorReporting } from './lib/client-error';
|
||||||
import { prefetchShareBrandAssets } from './lib/wechat-share';
|
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
|
||||||
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
|
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
|
||||||
patchTaroH5Hooks();
|
patchTaroH5Hooks();
|
||||||
installClientErrorReporting();
|
installClientErrorReporting();
|
||||||
prefetchShareBrandAssets();
|
|
||||||
|
|
||||||
function App({ children }: PropsWithChildren) {
|
function App({ children }: PropsWithChildren) {
|
||||||
const handlingRef = useRef(false);
|
const handlingRef = useRef(false);
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
@@ -7,8 +7,6 @@ type ProductCarouselProps = {
|
|||||||
alt: string;
|
alt: string;
|
||||||
variant?: 'home' | 'detail' | 'store';
|
variant?: 'home' | 'detail' | 'store';
|
||||||
previewable?: boolean;
|
previewable?: boolean;
|
||||||
/** cover=aspectFill 裁剪铺满;contain=aspectFit 缩放完整显示(门店门头固定区) */
|
|
||||||
imageFit?: 'cover' | 'contain';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */
|
/** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */
|
||||||
@@ -17,14 +15,12 @@ export default function ProductCarousel({
|
|||||||
alt,
|
alt,
|
||||||
variant = 'detail',
|
variant = 'detail',
|
||||||
previewable = false,
|
previewable = false,
|
||||||
imageFit = 'cover',
|
|
||||||
}: ProductCarouselProps) {
|
}: ProductCarouselProps) {
|
||||||
const slides = images.length > 0 ? images : [''];
|
const slides = images.length > 0 ? images : [''];
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const prefix =
|
const prefix =
|
||||||
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
|
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
|
||||||
const isContain = imageFit === 'contain';
|
const imageMode = 'aspectFill';
|
||||||
const wrapClass = `${prefix}-wrap${isContain ? ` ${prefix}-wrap--contain` : ''}`;
|
|
||||||
|
|
||||||
function previewAt(index: number) {
|
function previewAt(index: number) {
|
||||||
const urls = slides.filter(Boolean);
|
const urls = slides.filter(Boolean);
|
||||||
@@ -34,7 +30,7 @@ export default function ProductCarousel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className={wrapClass}>
|
<View className={`${prefix}-wrap`}>
|
||||||
<Swiper
|
<Swiper
|
||||||
className={prefix}
|
className={prefix}
|
||||||
circular={slides.length > 1}
|
circular={slides.length > 1}
|
||||||
@@ -46,7 +42,7 @@ export default function ProductCarousel({
|
|||||||
<Image
|
<Image
|
||||||
className={`${prefix}-image`}
|
className={`${prefix}-image`}
|
||||||
src={src}
|
src={src}
|
||||||
mode={isContain ? 'aspectFit' : 'aspectFill'}
|
mode={imageMode}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
onClick={previewable ? () => previewAt(index) : undefined}
|
onClick={previewable ? () => previewAt(index) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ function resolveApiBase(): string {
|
|||||||
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
|
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
|
||||||
? TARO_APP_API_ORIGIN
|
? TARO_APP_API_ORIGIN
|
||||||
: process.env.TARO_ENV === 'h5'
|
: process.env.TARO_ENV === 'h5'
|
||||||
? 'http://localhost:3010'
|
? 'http://localhost:3000'
|
||||||
: '';
|
: '';
|
||||||
if (origin) {
|
if (origin) {
|
||||||
return `${origin.replace(/\/$/, '')}/api/v1`;
|
return `${origin.replace(/\/$/, '')}/api/v1`;
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import {
|
|
||||||
BRAND_LOGO_MARK_URL,
|
|
||||||
BRAND_LOGO_URL,
|
|
||||||
BRAND_LOGO_WIDE_URL,
|
|
||||||
CUSTOMER_SERVICE_PHONE,
|
|
||||||
QUALIFICATION_DISCLOSURE_URL,
|
|
||||||
type ClientRuntimeConfig,
|
|
||||||
} from '@dukang/shared-types';
|
|
||||||
import { fetchClientConfig } from './pay-wechat';
|
|
||||||
|
|
||||||
export type BrandAssets = {
|
|
||||||
brandLogoUrl: string;
|
|
||||||
brandLogoWideUrl: string;
|
|
||||||
brandLogoMarkUrl: string;
|
|
||||||
qualificationDisclosureUrl: string;
|
|
||||||
customerServicePhone: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FALLBACK: BrandAssets = {
|
|
||||||
brandLogoUrl: BRAND_LOGO_URL,
|
|
||||||
brandLogoWideUrl: BRAND_LOGO_WIDE_URL,
|
|
||||||
brandLogoMarkUrl: BRAND_LOGO_MARK_URL,
|
|
||||||
qualificationDisclosureUrl: QUALIFICATION_DISCLOSURE_URL,
|
|
||||||
customerServicePhone: CUSTOMER_SERVICE_PHONE,
|
|
||||||
};
|
|
||||||
|
|
||||||
let cached: BrandAssets | null = null;
|
|
||||||
let inflight: Promise<BrandAssets> | null = null;
|
|
||||||
|
|
||||||
function fromConfig(config: ClientRuntimeConfig | null | undefined): BrandAssets {
|
|
||||||
return {
|
|
||||||
brandLogoUrl: config?.brandLogoUrl?.trim() || FALLBACK.brandLogoUrl,
|
|
||||||
brandLogoWideUrl: config?.brandLogoWideUrl?.trim() || FALLBACK.brandLogoWideUrl,
|
|
||||||
brandLogoMarkUrl: config?.brandLogoMarkUrl?.trim() || FALLBACK.brandLogoMarkUrl,
|
|
||||||
qualificationDisclosureUrl:
|
|
||||||
config?.qualificationDisclosureUrl?.trim() || FALLBACK.qualificationDisclosureUrl,
|
|
||||||
customerServicePhone: config?.customerServicePhone?.trim() || FALLBACK.customerServicePhone,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 同步读取最近一次缓存(未拉取前返回代码默认常量) */
|
|
||||||
export function getBrandAssetsSync(): BrandAssets {
|
|
||||||
return cached ?? FALLBACK;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 拉取 /common/client-config 中的品牌与客服配置并缓存 */
|
|
||||||
export async function loadBrandAssets(force = false): Promise<BrandAssets> {
|
|
||||||
if (!force && cached) return cached;
|
|
||||||
if (!force && inflight) return inflight;
|
|
||||||
inflight = fetchClientConfig()
|
|
||||||
.then((cfg) => {
|
|
||||||
cached = fromConfig(cfg);
|
|
||||||
return cached;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
cached = FALLBACK;
|
|
||||||
return cached;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
inflight = null;
|
|
||||||
});
|
|
||||||
return inflight;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyBrandFromClientConfig(config: ClientRuntimeConfig | null | undefined) {
|
|
||||||
cached = fromConfig(config);
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro';
|
|||||||
import { fetchClientConfig } from './pay-wechat';
|
import { fetchClientConfig } from './pay-wechat';
|
||||||
|
|
||||||
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
||||||
export const APP_VERSION = '3.4.15';
|
export const APP_VERSION = '3.4.13';
|
||||||
|
|
||||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/;
|
const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/;
|
||||||
const LANDLINE_PHONE_RE = /^0\d{2,3}-?\d{7,8}(-\d{1,6})?$/;
|
|
||||||
|
|
||||||
export function normalizePhoneInput(value: string): string {
|
export function normalizePhoneInput(value: string): string {
|
||||||
return value.replace(/\D/g, '').slice(0, 11);
|
return value.replace(/\D/g, '').slice(0, 11);
|
||||||
@@ -19,41 +18,6 @@ export function validateMobilePhone(phone: string): { ok: boolean; message?: str
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeContactPhone(raw: string): string {
|
|
||||||
return String(raw ?? '')
|
|
||||||
.trim()
|
|
||||||
.replace(/\s+/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 脱敏展示:手机 138****8000;座机保留区号,如 0379-****888。
|
|
||||||
* 门店详情电话展示用(拨号仍走 toDialablePhone 明文)。
|
|
||||||
*/
|
|
||||||
export function maskPhone(phone: string) {
|
export function maskPhone(phone: string) {
|
||||||
const normalized = normalizeContactPhone(phone);
|
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||||
if (!normalized) return '';
|
|
||||||
if (MOBILE_PHONE_RE.test(normalized)) {
|
|
||||||
const digits = normalized.replace(/\D/g, '');
|
|
||||||
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
|
||||||
}
|
|
||||||
if (LANDLINE_PHONE_RE.test(normalized)) {
|
|
||||||
const extMatch = normalized.match(/-(\d{1,6})$/);
|
|
||||||
const hasExt = !!extMatch && normalized.indexOf('-') !== normalized.lastIndexOf('-');
|
|
||||||
const ext = hasExt ? extMatch![1] : '';
|
|
||||||
const main = hasExt ? normalized.slice(0, -(ext.length + 1)) : normalized;
|
|
||||||
const digits = main.replace(/\D/g, '');
|
|
||||||
const areaLen = digits.startsWith('01') || digits.startsWith('02') ? 3 : 4;
|
|
||||||
const area = digits.slice(0, areaLen);
|
|
||||||
const local = digits.slice(areaLen);
|
|
||||||
const keepTail = Math.min(4, Math.max(2, local.length - 4));
|
|
||||||
const maskedLocal =
|
|
||||||
local.length <= 4 ? '*'.repeat(local.length) : `${'*'.repeat(local.length - keepTail)}${local.slice(-keepTail)}`;
|
|
||||||
const joiner = main.includes('-') ? '-' : '';
|
|
||||||
return ext ? `${area}${joiner}${maskedLocal}-${ext}` : `${area}${joiner}${maskedLocal}`;
|
|
||||||
}
|
|
||||||
return normalized.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toDialablePhone(raw: string): string {
|
|
||||||
return String(raw ?? '').replace(/[\s-]/g, '');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +1,16 @@
|
|||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
|
import { BRAND_LOGO_URL } from '@dukang/shared-types';
|
||||||
import type { WechatShareData } from '@dukang/weixin-sdk';
|
import type { WechatShareData } from '@dukang/weixin-sdk';
|
||||||
import { getWechatShareLink, isWechatBrowser, isMiniProgram } from '@dukang/weixin-sdk';
|
import { getWechatShareLink, isWechatBrowser, isMiniProgram } from '@dukang/weixin-sdk';
|
||||||
import {
|
|
||||||
applyShareTitleTemplate,
|
|
||||||
DEFAULT_SHARE_DESC,
|
|
||||||
DEFAULT_SHARE_HINT,
|
|
||||||
DEFAULT_SHARE_TITLE,
|
|
||||||
resolveMiniShareRuntime,
|
|
||||||
type ClientRuntimeConfig,
|
|
||||||
type MiniShareRuntime,
|
|
||||||
type MiniShareSceneConfig,
|
|
||||||
} from '@dukang/shared-types';
|
|
||||||
import { toast } from './api';
|
import { toast } from './api';
|
||||||
import { getBrandAssetsSync, loadBrandAssets } from './brand-assets';
|
|
||||||
import { fetchClientConfig } from './pay-wechat';
|
|
||||||
import { isWechatEnv, weixinSdk } from './weixin';
|
import { isWechatEnv, weixinSdk } from './weixin';
|
||||||
|
|
||||||
export type ShareScene =
|
export const DEFAULT_SHARE_TITLE = '你吃饭,杜康买单';
|
||||||
| 'home'
|
export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用';
|
||||||
| 'stores'
|
export const WECHAT_SHARE_HINT = '请点击右上角 ··· 分享给好友';
|
||||||
| 'storeDetail'
|
|
||||||
| 'benefit'
|
|
||||||
| 'mine'
|
|
||||||
| 'productDetail'
|
|
||||||
| 'orderDetail';
|
|
||||||
|
|
||||||
export { DEFAULT_SHARE_TITLE, DEFAULT_SHARE_DESC };
|
|
||||||
|
|
||||||
export const WECHAT_SHARE_HINT = DEFAULT_SHARE_HINT;
|
|
||||||
|
|
||||||
const FALLBACK_SHARE: MiniShareRuntime = resolveMiniShareRuntime({});
|
|
||||||
|
|
||||||
let shareCached: MiniShareRuntime | null = null;
|
|
||||||
|
|
||||||
export function getShareRuntimeSync(): MiniShareRuntime {
|
|
||||||
return shareCached ?? FALLBACK_SHARE;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyShareFromClientConfig(config?: ClientRuntimeConfig | null) {
|
|
||||||
if (config?.share) {
|
|
||||||
shareCached = config.share;
|
|
||||||
return shareCached;
|
|
||||||
}
|
|
||||||
return getShareRuntimeSync();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadShareConfig(force = false): Promise<MiniShareRuntime> {
|
|
||||||
if (!force && shareCached) return shareCached;
|
|
||||||
try {
|
|
||||||
const cfg = await fetchClientConfig();
|
|
||||||
if (cfg?.share) {
|
|
||||||
shareCached = cfg.share;
|
|
||||||
return shareCached;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
shareCached = FALLBACK_SHARE;
|
|
||||||
return shareCached;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultShareImageUrl(): string {
|
export function getDefaultShareImageUrl(): string {
|
||||||
const share = getShareRuntimeSync();
|
return BRAND_LOGO_URL;
|
||||||
return share.default.imageUrl || getBrandAssetsSync().brandLogoUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getShareHint(): string {
|
|
||||||
return getShareRuntimeSync().hint || DEFAULT_SHARE_HINT;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 预热分享配置与品牌图 */
|
|
||||||
export function prefetchShareBrandAssets() {
|
|
||||||
void loadBrandAssets();
|
|
||||||
void loadShareConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
function sceneConfig(scene?: ShareScene): MiniShareSceneConfig {
|
|
||||||
const runtime = getShareRuntimeSync();
|
|
||||||
if (!scene) return runtime.default;
|
|
||||||
return runtime[scene] ?? runtime.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 组装页面分享:场景配置优先;空字段用 dynamic → 默认分享。
|
|
||||||
* orderDetail 标题支持 {productName}。
|
|
||||||
*/
|
|
||||||
export function buildSceneSharePayload(
|
|
||||||
scene: ShareScene,
|
|
||||||
options?: {
|
|
||||||
path?: string;
|
|
||||||
/** 业务动态标题(场景配置为空时使用) */
|
|
||||||
dynamicTitle?: string | null;
|
|
||||||
dynamicDesc?: string | null;
|
|
||||||
dynamicImageUrl?: string | null;
|
|
||||||
titleVars?: Record<string, string | undefined | null>;
|
|
||||||
},
|
|
||||||
): PageSharePayload {
|
|
||||||
const def = getShareRuntimeSync().default;
|
|
||||||
const sc = sceneConfig(scene);
|
|
||||||
let title = (sc.title || '').trim();
|
|
||||||
if (title && options?.titleVars) {
|
|
||||||
const vars = options.titleVars;
|
|
||||||
const missingRequired = Object.entries(vars).some(
|
|
||||||
([key, value]) => title.includes(`{${key}}`) && !(value != null && String(value).trim()),
|
|
||||||
);
|
|
||||||
title = missingRequired ? '' : applyShareTitleTemplate(title, vars);
|
|
||||||
}
|
|
||||||
if (!title) {
|
|
||||||
title = (options?.dynamicTitle || '').trim() || def.title;
|
|
||||||
}
|
|
||||||
const desc = (sc.desc || '').trim() || (options?.dynamicDesc || '').trim() || def.desc;
|
|
||||||
const imgUrl =
|
|
||||||
(sc.imageUrl || '').trim() ||
|
|
||||||
(options?.dynamicImageUrl || '').trim() ||
|
|
||||||
def.imageUrl ||
|
|
||||||
getDefaultShareImageUrl();
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
desc,
|
|
||||||
path: options?.path,
|
|
||||||
imgUrl,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDefaultShareData(
|
export function buildDefaultShareData(
|
||||||
@@ -134,10 +24,9 @@ export function buildDefaultShareData(
|
|||||||
link = '';
|
link = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const def = getShareRuntimeSync().default;
|
|
||||||
return {
|
return {
|
||||||
title: overrides?.title ?? def.title,
|
title: overrides?.title ?? DEFAULT_SHARE_TITLE,
|
||||||
desc: overrides?.desc ?? def.desc,
|
desc: overrides?.desc ?? DEFAULT_SHARE_DESC,
|
||||||
link,
|
link,
|
||||||
imgUrl: overrides?.imgUrl ?? getDefaultShareImageUrl(),
|
imgUrl: overrides?.imgUrl ?? getDefaultShareImageUrl(),
|
||||||
};
|
};
|
||||||
@@ -179,7 +68,7 @@ export async function handleShareButtonClick(
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
toast(getShareHint());
|
toast(WECHAT_SHARE_HINT);
|
||||||
return { showGuide: false };
|
return { showGuide: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +89,7 @@ export async function handleShareButtonClick(
|
|||||||
if (result.invoked) {
|
if (result.invoked) {
|
||||||
return { showGuide: false };
|
return { showGuide: false };
|
||||||
}
|
}
|
||||||
toast(getShareHint());
|
toast(WECHAT_SHARE_HINT);
|
||||||
return { showGuide: true };
|
return { showGuide: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : '分享配置失败,请刷新后重试');
|
toast(e instanceof Error ? e.message : '分享配置失败,请刷新后重试');
|
||||||
@@ -210,20 +99,9 @@ export async function handleShareButtonClick(
|
|||||||
|
|
||||||
/** 供 useShareAppMessage 使用的标题/路径/图 */
|
/** 供 useShareAppMessage 使用的标题/路径/图 */
|
||||||
export function toWeappShareMessage(payload?: PageSharePayload) {
|
export function toWeappShareMessage(payload?: PageSharePayload) {
|
||||||
const def = getShareRuntimeSync().default;
|
|
||||||
return {
|
return {
|
||||||
title: payload?.title || def.title,
|
title: payload?.title || DEFAULT_SHARE_TITLE,
|
||||||
path: payload?.path || '/pages/home/index',
|
path: payload?.path || '/pages/home/index',
|
||||||
imageUrl: payload?.imgUrl || getDefaultShareImageUrl(),
|
imageUrl: payload?.imgUrl || getDefaultShareImageUrl(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 供 useShareTimeline */
|
|
||||||
export function toWeappShareTimeline(payload?: PageSharePayload, query = '') {
|
|
||||||
const def = getShareRuntimeSync().default;
|
|
||||||
return {
|
|
||||||
title: payload?.title || def.title,
|
|
||||||
query,
|
|
||||||
imageUrl: payload?.imgUrl || getDefaultShareImageUrl(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { goLogin } from '../../lib/auth-nav';
|
|||||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||||
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../../lib/nav-bar';
|
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../../lib/nav-bar';
|
||||||
import {
|
import {
|
||||||
buildSceneSharePayload,
|
DEFAULT_SHARE_DESC,
|
||||||
|
DEFAULT_SHARE_TITLE,
|
||||||
toWeappShareMessage,
|
toWeappShareMessage,
|
||||||
toWeappShareTimeline,
|
|
||||||
} from '../../lib/wechat-share';
|
} from '../../lib/wechat-share';
|
||||||
import { formatMoney } from '../../lib/money';
|
import { formatMoney } from '../../lib/money';
|
||||||
import iconBenefit from '../../assets/tabbar/benefit-active.png';
|
import iconBenefit from '../../assets/tabbar/benefit-active.png';
|
||||||
@@ -104,15 +104,20 @@ export default function BenefitPage() {
|
|||||||
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
|
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
|
||||||
|
|
||||||
const sharePayload = useMemo(
|
const sharePayload = useMemo(
|
||||||
() =>
|
() => ({
|
||||||
buildSceneSharePayload('benefit', {
|
title: '好客权益 · 杜康好客',
|
||||||
|
desc: DEFAULT_SHARE_DESC,
|
||||||
path: '/pages/benefit/index',
|
path: '/pages/benefit/index',
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||||
useShareTimeline(() => toWeappShareTimeline(sharePayload));
|
useShareTimeline(() => ({
|
||||||
|
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||||
|
query: '',
|
||||||
|
imageUrl: sharePayload.imgUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="tab" className="benefit-page">
|
<PageShell variant="tab" className="benefit-page">
|
||||||
|
|||||||
@@ -1,26 +1,19 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
|
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
import ContactCsButton from '../../components/ContactCsButton';
|
import ContactCsButton from '../../components/ContactCsButton';
|
||||||
import { toast } from '../../lib/api';
|
import { toast } from '../../lib/api';
|
||||||
import { getBrandAssetsSync, loadBrandAssets } from '../../lib/brand-assets';
|
|
||||||
|
|
||||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||||
|
const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||||
|
|
||||||
function dialPhone(phone: string) {
|
function dialPhone() {
|
||||||
const tel = phone.replace(/-/g, '');
|
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() => toast('无法拨打电话'));
|
||||||
Taro.makePhoneCall({ phoneNumber: tel }).catch(() => toast('无法拨打电话'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomerServicePage() {
|
export default function CustomerServicePage() {
|
||||||
const [phone, setPhone] = useState(() => getBrandAssetsSync().customerServicePhone);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadBrandAssets().then((b) => setPhone(b.customerServicePhone));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="sub" className="cs-page">
|
<PageShell variant="sub" className="cs-page">
|
||||||
<SubPageHeader title="联系客服" />
|
<SubPageHeader title="联系客服" />
|
||||||
@@ -37,14 +30,14 @@ export default function CustomerServicePage() {
|
|||||||
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
|
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={() => dialPhone(phone)}>
|
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={dialPhone}>
|
||||||
<Text>
|
<Text>
|
||||||
{isWeapp ? `或拨打客服电话 ${phone}` : '拨打客服电话'}
|
{isWeapp ? `或拨打客服电话 ${CUSTOMER_SERVICE_PHONE}` : '拨打客服电话'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{!isWeapp ? (
|
{!isWeapp ? (
|
||||||
<Text className="cs-phone-display">{phone}</Text>
|
<Text className="cs-phone-display">{CUSTOMER_SERVICE_PHONE}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -28,12 +28,10 @@ import {
|
|||||||
normalizeFulfillmentFlags,
|
normalizeFulfillmentFlags,
|
||||||
} from '../../lib/product-fulfillment';
|
} from '../../lib/product-fulfillment';
|
||||||
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
||||||
import type { ClientRuntimeConfig } from '@dukang/shared-types';
|
|
||||||
import {
|
import {
|
||||||
applyShareFromClientConfig,
|
DEFAULT_SHARE_DESC,
|
||||||
buildSceneSharePayload,
|
DEFAULT_SHARE_TITLE,
|
||||||
toWeappShareMessage,
|
toWeappShareMessage,
|
||||||
toWeappShareTimeline,
|
|
||||||
} from '../../lib/wechat-share';
|
} from '../../lib/wechat-share';
|
||||||
import { trackPageView } from '../../lib/analytics';
|
import { trackPageView } from '../../lib/analytics';
|
||||||
type Product = {
|
type Product = {
|
||||||
@@ -87,9 +85,8 @@ export default function HomePage() {
|
|||||||
}, [cityCode]);
|
}, [cityCode]);
|
||||||
|
|
||||||
const loadMiniHome = useCallback(() => {
|
const loadMiniHome = useCallback(() => {
|
||||||
return request<ClientRuntimeConfig>('/common/client-config')
|
return request<{ miniHome?: MiniHomeConfig }>('/common/client-config')
|
||||||
.then((cfg) => {
|
.then((cfg) => {
|
||||||
applyShareFromClientConfig(cfg);
|
|
||||||
const banners = Array.isArray(cfg.miniHome?.banners)
|
const banners = Array.isArray(cfg.miniHome?.banners)
|
||||||
? cfg.miniHome!.banners.filter((u) => typeof u === 'string' && !!u.trim())
|
? cfg.miniHome!.banners.filter((u) => typeof u === 'string' && !!u.trim())
|
||||||
: [];
|
: [];
|
||||||
@@ -224,16 +221,21 @@ export default function HomePage() {
|
|||||||
const footerUrl = miniHome.footerUrl;
|
const footerUrl = miniHome.footerUrl;
|
||||||
|
|
||||||
const sharePayload = useMemo(
|
const sharePayload = useMemo(
|
||||||
() =>
|
() => ({
|
||||||
buildSceneSharePayload('home', {
|
title: DEFAULT_SHARE_TITLE,
|
||||||
|
desc: DEFAULT_SHARE_DESC,
|
||||||
path: '/pages/home/index',
|
path: '/pages/home/index',
|
||||||
dynamicImageUrl: banners[0] || undefined,
|
imgUrl: banners[0] || undefined,
|
||||||
}),
|
}),
|
||||||
[banners],
|
[banners],
|
||||||
);
|
);
|
||||||
|
|
||||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||||
useShareTimeline(() => toWeappShareTimeline(sharePayload));
|
useShareTimeline(() => ({
|
||||||
|
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||||
|
query: '',
|
||||||
|
imageUrl: sharePayload.imgUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
function scrollToAroma(key: AromaKey) {
|
function scrollToAroma(key: AromaKey) {
|
||||||
setActiveAroma(key);
|
setActiveAroma(key);
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ import {
|
|||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import WechatLoginButton from '../../components/WechatLoginButton';
|
import WechatLoginButton from '../../components/WechatLoginButton';
|
||||||
import PhoneQuickLoginButton from '../../components/PhoneQuickLoginButton';
|
import PhoneQuickLoginButton from '../../components/PhoneQuickLoginButton';
|
||||||
import {
|
import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types';
|
||||||
applyBrandFromClientConfig,
|
|
||||||
getBrandAssetsSync,
|
|
||||||
loadBrandAssets,
|
|
||||||
} from '../../lib/brand-assets';
|
|
||||||
import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav';
|
import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav';
|
||||||
import {
|
import {
|
||||||
bindWechatForUser,
|
bindWechatForUser,
|
||||||
@@ -102,18 +98,11 @@ export default function LoginPage() {
|
|||||||
const [wxAuthorize, setWxAuthorize] = useState(true);
|
const [wxAuthorize, setWxAuthorize] = useState(true);
|
||||||
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
|
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
|
||||||
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP);
|
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP);
|
||||||
const [logoWideUrl, setLogoWideUrl] = useState(() => getBrandAssetsSync().brandLogoWideUrl);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<ClientRuntimeConfig>('/common/client-config')
|
request<ClientRuntimeConfig>('/common/client-config')
|
||||||
.then((config) => {
|
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||||
setWxAuthorize(isWxAuthorizeEnabled(config));
|
.catch(() => setWxAuthorize(true));
|
||||||
setLogoWideUrl(applyBrandFromClientConfig(config).brandLogoWideUrl);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setWxAuthorize(true);
|
|
||||||
void loadBrandAssets().then((b) => setLogoWideUrl(b.brandLogoWideUrl));
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -405,7 +394,7 @@ export default function LoginPage() {
|
|||||||
<View className="login-header">
|
<View className="login-header">
|
||||||
<View className="login-logo-wrap">
|
<View className="login-logo-wrap">
|
||||||
<View className="login-logo">
|
<View className="login-logo">
|
||||||
<Image className="login-logo-img" src={logoWideUrl} mode="aspectFit" />
|
<Image className="login-logo-img" src={BRAND_LOGO_WIDE_URL} mode="aspectFit" />
|
||||||
</View>
|
</View>
|
||||||
<Text className="login-logo-badge">官方</Text>
|
<Text className="login-logo-badge">官方</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { View, Text, Image, Button, Input, ScrollView } from '@tarojs/components';
|
import { View, Text, Image, Button, Input, ScrollView } from '@tarojs/components';
|
||||||
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||||
import {
|
import {
|
||||||
|
BRAND_LOGO_MARK_URL,
|
||||||
|
QUALIFICATION_DISCLOSURE_URL,
|
||||||
isWxAuthorizeEnabled,
|
isWxAuthorizeEnabled,
|
||||||
type ClientRuntimeConfig,
|
type ClientRuntimeConfig,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
@@ -11,11 +13,6 @@ import WechatShareReady from '../../components/WechatShareReady';
|
|||||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||||
import { goLogin } from '../../lib/auth-nav';
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
import { bindWechatForUser } from '../../lib/wechat-auth';
|
import { bindWechatForUser } from '../../lib/wechat-auth';
|
||||||
import {
|
|
||||||
applyBrandFromClientConfig,
|
|
||||||
getBrandAssetsSync,
|
|
||||||
loadBrandAssets,
|
|
||||||
} from '../../lib/brand-assets';
|
|
||||||
import {
|
import {
|
||||||
fetchMiniWechatUserInfo,
|
fetchMiniWechatUserInfo,
|
||||||
isDefaultMiniNickname,
|
isDefaultMiniNickname,
|
||||||
@@ -29,10 +26,9 @@ import { isWechatEnv } from '../../lib/weixin';
|
|||||||
import { APP_VERSION_LABEL } from '../../lib/client-version';
|
import { APP_VERSION_LABEL } from '../../lib/client-version';
|
||||||
import { maskPhone } from '../../lib/phone';
|
import { maskPhone } from '../../lib/phone';
|
||||||
import {
|
import {
|
||||||
applyShareFromClientConfig,
|
DEFAULT_SHARE_DESC,
|
||||||
buildSceneSharePayload,
|
DEFAULT_SHARE_TITLE,
|
||||||
toWeappShareMessage,
|
toWeappShareMessage,
|
||||||
toWeappShareTimeline,
|
|
||||||
} from '../../lib/wechat-share';
|
} from '../../lib/wechat-share';
|
||||||
import iconPendingPay from '../../assets/icons/待付款.png';
|
import iconPendingPay from '../../assets/icons/待付款.png';
|
||||||
import iconPaid from '../../assets/icons/已付款.png';
|
import iconPaid from '../../assets/icons/已付款.png';
|
||||||
@@ -74,11 +70,7 @@ export default function MinePage() {
|
|||||||
const [savingProfile, setSavingProfile] = useState(false);
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
const [profileLoadError, setProfileLoadError] = useState('');
|
const [profileLoadError, setProfileLoadError] = useState('');
|
||||||
const [qualificationOpen, setQualificationOpen] = useState(false);
|
const [qualificationOpen, setQualificationOpen] = useState(false);
|
||||||
const [brandMarkUrl, setBrandMarkUrl] = useState(() => getBrandAssetsSync().brandLogoMarkUrl);
|
|
||||||
const [qualificationUrl, setQualificationUrl] = useState(
|
|
||||||
() => getBrandAssetsSync().qualificationDisclosureUrl,
|
|
||||||
);
|
|
||||||
const [shareTick, setShareTick] = useState(0);
|
|
||||||
function resetGuestState() {
|
function resetGuestState() {
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setBenefitBalance(0);
|
setBenefitBalance(0);
|
||||||
@@ -149,30 +141,24 @@ export default function MinePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<ClientRuntimeConfig>('/common/client-config')
|
request<ClientRuntimeConfig>('/common/client-config')
|
||||||
.then((config) => {
|
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||||
setWxAuthorize(isWxAuthorizeEnabled(config));
|
.catch(() => setWxAuthorize(true));
|
||||||
const brand = applyBrandFromClientConfig(config);
|
|
||||||
applyShareFromClientConfig(config);
|
|
||||||
setBrandMarkUrl(brand.brandLogoMarkUrl);
|
|
||||||
setQualificationUrl(brand.qualificationDisclosureUrl);
|
|
||||||
setShareTick((n) => n + 1);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setWxAuthorize(true);
|
|
||||||
void loadBrandAssets();
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sharePayload = useMemo(
|
const sharePayload = useMemo(
|
||||||
() =>
|
() => ({
|
||||||
buildSceneSharePayload('mine', {
|
title: '杜康好客 · 我的',
|
||||||
|
desc: DEFAULT_SHARE_DESC,
|
||||||
path: '/pages/mine/index',
|
path: '/pages/mine/index',
|
||||||
}),
|
}),
|
||||||
[shareTick],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||||
useShareTimeline(() => toWeappShareTimeline(sharePayload));
|
useShareTimeline(() => ({
|
||||||
|
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||||
|
query: '',
|
||||||
|
}));
|
||||||
|
|
||||||
async function ensureWechatBound(): Promise<boolean> {
|
async function ensureWechatBound(): Promise<boolean> {
|
||||||
if (profile?.hasWechat) return true;
|
if (profile?.hasWechat) return true;
|
||||||
@@ -318,7 +304,7 @@ export default function MinePage() {
|
|||||||
if (displayAvatarUrl) {
|
if (displayAvatarUrl) {
|
||||||
return <Image className="mine-avatar-img" src={displayAvatarUrl} mode="aspectFill" />;
|
return <Image className="mine-avatar-img" src={displayAvatarUrl} mode="aspectFill" />;
|
||||||
}
|
}
|
||||||
return <Image className="mine-avatar-img" src={brandMarkUrl} mode="aspectFit" />;
|
return <Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authed) {
|
if (!authed) {
|
||||||
@@ -331,7 +317,7 @@ export default function MinePage() {
|
|||||||
<View className="mine-profile">
|
<View className="mine-profile">
|
||||||
<View className="mine-avatar-wrap mine-avatar-wrap--action" onClick={() => goLogin('/pages/mine/index')}>
|
<View className="mine-avatar-wrap mine-avatar-wrap--action" onClick={() => goLogin('/pages/mine/index')}>
|
||||||
<View className="mine-avatar mine-avatar--wx-pending">
|
<View className="mine-avatar mine-avatar--wx-pending">
|
||||||
<Image className="mine-avatar-img" src={brandMarkUrl} mode="aspectFit" />
|
<Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View>
|
<View>
|
||||||
@@ -524,13 +510,8 @@ export default function MinePage() {
|
|||||||
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
|
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
|
||||||
|
|
||||||
{profileSheetOpen ? (
|
{profileSheetOpen ? (
|
||||||
<View className="mine-profile-sheet-mask">
|
<View className="mine-profile-sheet-mask" onClick={() => !savingProfile && setProfileSheetOpen(false)}>
|
||||||
{/* 遮罩单独绑 tap,勿在含 chooseAvatar 的祖先上用 stopPropagation(会编译成 catchtap 导致选头像无反应) */}
|
<View className="mine-profile-sheet" onClick={(e) => e.stopPropagation()}>
|
||||||
<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-title">完善头像与昵称</Text>
|
||||||
<Text className="mine-profile-sheet-hint">
|
<Text className="mine-profile-sheet-hint">
|
||||||
点击头像选择,并填写昵称后保存(用于个人中心展示)
|
点击头像选择,并填写昵称后保存(用于个人中心展示)
|
||||||
@@ -539,14 +520,13 @@ export default function MinePage() {
|
|||||||
className="mine-profile-avatar-btn"
|
className="mine-profile-avatar-btn"
|
||||||
openType="chooseAvatar"
|
openType="chooseAvatar"
|
||||||
hoverClass="none"
|
hoverClass="none"
|
||||||
plain
|
|
||||||
onChooseAvatar={onChooseAvatar}
|
onChooseAvatar={onChooseAvatar}
|
||||||
>
|
>
|
||||||
<View className="mine-profile-avatar-preview">
|
<View className="mine-profile-avatar-preview">
|
||||||
{previewAvatar ? (
|
{previewAvatar ? (
|
||||||
<Image className="mine-avatar-img" src={previewAvatar} mode="aspectFill" />
|
<Image className="mine-avatar-img" src={previewAvatar} mode="aspectFill" />
|
||||||
) : (
|
) : (
|
||||||
<Image className="mine-avatar-img" src={brandMarkUrl} mode="aspectFit" />
|
<Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text className="mine-profile-avatar-tip">点击选择头像</Text>
|
<Text className="mine-profile-avatar-tip">点击选择头像</Text>
|
||||||
@@ -599,7 +579,7 @@ export default function MinePage() {
|
|||||||
<View className="mine-qualification-body">
|
<View className="mine-qualification-body">
|
||||||
<Image
|
<Image
|
||||||
className="mine-qualification-img"
|
className="mine-qualification-img"
|
||||||
src={qualificationUrl}
|
src={QUALIFICATION_DISCLOSURE_URL}
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export default function OrderConfirmPickupPage() {
|
|||||||
|
|
||||||
const { confirm } = await Taro.showModal({
|
const { confirm } = await Taro.showModal({
|
||||||
title: '确认提交订单',
|
title: '确认提交订单',
|
||||||
content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家。`,
|
content: `确认提交现场提货订单?共 ${quantity} 瓶,应付 ¥${Number(preview?.payAmount ?? 0).toFixed(2)}。`,
|
||||||
confirmText: '确认提交',
|
confirmText: '确认提交',
|
||||||
cancelText: '再想想',
|
cancelText: '再想想',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ import { fetchOrderTrack } from '../../lib/order-logistics';
|
|||||||
import { maskPhone } from '../../lib/phone';
|
import { maskPhone } from '../../lib/phone';
|
||||||
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
|
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
|
||||||
import {
|
import {
|
||||||
buildSceneSharePayload,
|
DEFAULT_SHARE_DESC,
|
||||||
|
DEFAULT_SHARE_TITLE,
|
||||||
toWeappShareMessage,
|
toWeappShareMessage,
|
||||||
toWeappShareTimeline,
|
|
||||||
} from '../../lib/wechat-share';
|
} from '../../lib/wechat-share';
|
||||||
import { usePageView } from '../../lib/usePageView';
|
import { usePageView } from '../../lib/usePageView';
|
||||||
|
|
||||||
@@ -174,20 +174,19 @@ export default function OrderDetailPage() {
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
const sharePayload = useMemo(
|
const sharePayload = useMemo(
|
||||||
() =>
|
() => ({
|
||||||
buildSceneSharePayload('orderDetail', {
|
title: productName !== '杜康商品' ? `我买了${productName} · 杜康好客` : DEFAULT_SHARE_TITLE,
|
||||||
|
desc: DEFAULT_SHARE_DESC,
|
||||||
path: orderId ? `/pages/order-detail/index?id=${orderId}` : '/pages/home/index',
|
path: orderId ? `/pages/order-detail/index?id=${orderId}` : '/pages/home/index',
|
||||||
titleVars: {
|
|
||||||
productName: productName && productName !== '杜康商品' ? productName : '',
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
[productName, orderId],
|
[productName, orderId],
|
||||||
);
|
);
|
||||||
|
|
||||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||||
useShareTimeline(() =>
|
useShareTimeline(() => ({
|
||||||
toWeappShareTimeline(sharePayload, orderId ? `id=${orderId}` : ''),
|
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||||
);
|
query: orderId ? `id=${orderId}` : '',
|
||||||
|
}));
|
||||||
|
|
||||||
function goPay() {
|
function goPay() {
|
||||||
if (!order) return;
|
if (!order) return;
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ import {
|
|||||||
normalizeFulfillmentFlags,
|
normalizeFulfillmentFlags,
|
||||||
} from '../../lib/product-fulfillment';
|
} from '../../lib/product-fulfillment';
|
||||||
import {
|
import {
|
||||||
buildSceneSharePayload,
|
DEFAULT_SHARE_DESC,
|
||||||
|
DEFAULT_SHARE_TITLE,
|
||||||
toWeappShareMessage,
|
toWeappShareMessage,
|
||||||
toWeappShareTimeline,
|
|
||||||
} from '../../lib/wechat-share';
|
} from '../../lib/wechat-share';
|
||||||
import iconHome from '../../assets/tabbar/home.png';
|
import iconHome from '../../assets/tabbar/home.png';
|
||||||
import { usePageView } from '../../lib/usePageView';
|
import { usePageView } from '../../lib/usePageView';
|
||||||
@@ -88,20 +88,21 @@ export default function ProductDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sharePayload = useMemo(
|
const sharePayload = useMemo(
|
||||||
() =>
|
() => ({
|
||||||
buildSceneSharePayload('productDetail', {
|
title: product?.name || DEFAULT_SHARE_TITLE,
|
||||||
|
desc: product?.subtitle || DEFAULT_SHARE_DESC,
|
||||||
path: `/pages/product-detail/index?id=${productId}`,
|
path: `/pages/product-detail/index?id=${productId}`,
|
||||||
dynamicTitle: product?.name,
|
imgUrl: (product ? getProductMainImage(product) : '') || undefined,
|
||||||
dynamicDesc: product?.subtitle,
|
|
||||||
dynamicImageUrl: product ? getProductMainImage(product) : undefined,
|
|
||||||
}),
|
}),
|
||||||
[product, productId],
|
[product, productId],
|
||||||
);
|
);
|
||||||
|
|
||||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||||
useShareTimeline(() =>
|
useShareTimeline(() => ({
|
||||||
toWeappShareTimeline(sharePayload, productId ? `id=${productId}` : ''),
|
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||||
);
|
query: productId ? `id=${productId}` : '',
|
||||||
|
imageUrl: sharePayload.imgUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
const pages = Taro.getCurrentPages();
|
const pages = Taro.getCurrentPages();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
import { View, Text, Image, ScrollView } from '@tarojs/components';
|
||||||
import Taro, {
|
import Taro, {
|
||||||
useDidShow,
|
useDidShow,
|
||||||
useLoad,
|
useLoad,
|
||||||
@@ -15,13 +15,13 @@ import ShareNavButton from '../../components/ShareNavButton';
|
|||||||
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
|
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import { maskPhone, toDialablePhone } from '../../lib/phone';
|
import { maskPhone } from '../../lib/phone';
|
||||||
import { track } from '../../lib/analytics';
|
import { track } from '../../lib/analytics';
|
||||||
import { formatShanghaiDateTime } from '../../lib/datetime';
|
import { formatShanghaiDateTime } from '../../lib/datetime';
|
||||||
import {
|
import {
|
||||||
buildSceneSharePayload,
|
DEFAULT_SHARE_DESC,
|
||||||
|
DEFAULT_SHARE_TITLE,
|
||||||
toWeappShareMessage,
|
toWeappShareMessage,
|
||||||
toWeappShareTimeline,
|
|
||||||
} from '../../lib/wechat-share';
|
} from '../../lib/wechat-share';
|
||||||
|
|
||||||
type StoreMedia = {
|
type StoreMedia = {
|
||||||
@@ -108,13 +108,6 @@ function formatRedeemTime(input?: string | null) {
|
|||||||
return formatShanghaiDateTime(input);
|
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) {
|
function formatRedeemAmountYuan(amount: number | string) {
|
||||||
const n = typeof amount === 'number' ? amount : Number(amount);
|
const n = typeof amount === 'number' ? amount : Number(amount);
|
||||||
if (!Number.isFinite(n)) return '0';
|
if (!Number.isFinite(n)) return '0';
|
||||||
@@ -163,9 +156,14 @@ export default function StoreDetailPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [headerSolid, setHeaderSolid] = useState(false);
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
|
const [activePackageIndex, setActivePackageIndex] = useState(0);
|
||||||
const storeRef = useRef<Store | null>(null);
|
const storeRef = useRef<Store | null>(null);
|
||||||
storeRef.current = store;
|
storeRef.current = store;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActivePackageIndex(0);
|
||||||
|
}, [store?.id]);
|
||||||
|
|
||||||
usePageScroll(({ scrollTop }) => {
|
usePageScroll(({ scrollTop }) => {
|
||||||
setHeaderSolid(scrollTop > 100);
|
setHeaderSolid(scrollTop > 100);
|
||||||
});
|
});
|
||||||
@@ -244,15 +242,18 @@ export default function StoreDetailPage() {
|
|||||||
void loadRecentRedeems(id);
|
void loadRecentRedeems(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const sharePayload = useMemo(() => {
|
const sharePayload = useMemo(
|
||||||
|
() => {
|
||||||
const envFirst = store ? envPhotoUrls(store)[0] : undefined;
|
const envFirst = store ? envPhotoUrls(store)[0] : undefined;
|
||||||
return buildSceneSharePayload('storeDetail', {
|
return {
|
||||||
|
title: store?.name || DEFAULT_SHARE_TITLE,
|
||||||
|
desc: store?.intro?.trim() || store?.address || DEFAULT_SHARE_DESC,
|
||||||
path: `/pages/store-detail/index?id=${storeId}`,
|
path: `/pages/store-detail/index?id=${storeId}`,
|
||||||
dynamicTitle: store?.name,
|
imgUrl: store?.coverUrl || envFirst || store?.carouselUrls?.[0] || undefined,
|
||||||
dynamicDesc: store?.intro?.trim() || store?.address,
|
};
|
||||||
dynamicImageUrl: store?.coverUrl || envFirst || store?.carouselUrls?.[0],
|
},
|
||||||
});
|
[store, storeId],
|
||||||
}, [store, storeId]);
|
);
|
||||||
|
|
||||||
const marqueeLines = useMemo(
|
const marqueeLines = useMemo(
|
||||||
() => recentRedeems.map(formatRecentRedeemLine).filter(Boolean),
|
() => recentRedeems.map(formatRecentRedeemLine).filter(Boolean),
|
||||||
@@ -260,9 +261,11 @@ export default function StoreDetailPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||||
useShareTimeline(() =>
|
useShareTimeline(() => ({
|
||||||
toWeappShareTimeline(sharePayload, storeId ? `id=${storeId}` : ''),
|
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||||
);
|
query: storeId ? `id=${storeId}` : '',
|
||||||
|
imageUrl: sharePayload.imgUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
const pages = Taro.getCurrentPages();
|
const pages = Taro.getCurrentPages();
|
||||||
@@ -276,7 +279,7 @@ export default function StoreDetailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
track('store_phone_call', { storeId: store.id });
|
track('store_phone_call', { storeId: store.id });
|
||||||
Taro.makePhoneCall({ phoneNumber: toDialablePhone(store.phone) }).catch(() => toast('无法拨打电话'));
|
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMap() {
|
function openMap() {
|
||||||
@@ -323,18 +326,13 @@ export default function StoreDetailPage() {
|
|||||||
const envPhotos = envPhotoUrls(store);
|
const envPhotos = envPhotoUrls(store);
|
||||||
const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]);
|
const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]);
|
||||||
const packages = store.packages ?? [];
|
const packages = store.packages ?? [];
|
||||||
|
const activePackage = packages[activePackageIndex] ?? packages[0];
|
||||||
|
|
||||||
const intro = store.intro?.trim() || '';
|
const intro = store.intro?.trim() || '';
|
||||||
const benefitRuleRaw = store.benefitUsageRule?.trim() || '';
|
const benefitRuleRaw = store.benefitUsageRule?.trim() || '';
|
||||||
const benefitRule =
|
const benefitRule =
|
||||||
benefitRuleRaw && !/^null$/i.test(benefitRuleRaw) ? benefitRuleRaw : '';
|
benefitRuleRaw && !/^null$/i.test(benefitRuleRaw) ? benefitRuleRaw : '';
|
||||||
|
|
||||||
function openPackageDetail(index: number) {
|
|
||||||
Taro.navigateTo({
|
|
||||||
url: `/pages/store-package-detail/index?storeId=${storeId}&index=${index}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function previewEnv(index: number) {
|
function previewEnv(index: number) {
|
||||||
if (!envPhotos.length) return;
|
if (!envPhotos.length) return;
|
||||||
Taro.previewImage({
|
Taro.previewImage({
|
||||||
@@ -355,13 +353,7 @@ export default function StoreDetailPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<View className="store-detail-hero full-bleed">
|
<View className="store-detail-hero full-bleed">
|
||||||
<ProductCarousel
|
<ProductCarousel images={heroImages} alt={store.name} variant="store" previewable />
|
||||||
images={heroImages}
|
|
||||||
alt={store.name}
|
|
||||||
variant="store"
|
|
||||||
previewable
|
|
||||||
imageFit="contain"
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="store-detail-info-card">
|
<View className="store-detail-info-card">
|
||||||
@@ -416,25 +408,49 @@ export default function StoreDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{packages.length > 0 ? (
|
{packages.length > 0 && activePackage ? (
|
||||||
<View className="store-detail-section store-detail-section--packages">
|
<View className="store-detail-section store-detail-section--packages">
|
||||||
<Text className="store-detail-section-title">门店套餐</Text>
|
<Text className="store-detail-section-title">门店套餐</Text>
|
||||||
<View className="store-detail-package-list">
|
{packages.length > 1 ? (
|
||||||
|
<ScrollView className="store-detail-package-tabs" scrollX showScrollbar={false} enhanced>
|
||||||
|
<View className="store-detail-package-tabs-inner">
|
||||||
{packages.map((pkg, index) => (
|
{packages.map((pkg, index) => (
|
||||||
<View
|
<View
|
||||||
key={`${pkg.name}-${index}`}
|
key={`${pkg.name}-${index}`}
|
||||||
className="store-detail-package-list-item"
|
className={`store-detail-package-tab${
|
||||||
onClick={() => openPackageDetail(index)}
|
index === activePackageIndex ? ' store-detail-package-tab--active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => setActivePackageIndex(index)}
|
||||||
>
|
>
|
||||||
<View className="store-detail-package-list-row">
|
<Text className="store-detail-package-tab-text">{pkg.name}</Text>
|
||||||
<Text className="store-detail-package-list-title">{pkg.name}</Text>
|
|
||||||
<Text className="store-detail-package-list-price">
|
|
||||||
¥{formatPackagePriceYuan(pkg.price)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
) : null}
|
||||||
|
<View className="store-detail-package-panel">
|
||||||
|
{activePackage.imageUrl ? (
|
||||||
|
<Image
|
||||||
|
className="store-detail-package-thumb"
|
||||||
|
src={activePackage.imageUrl}
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<View className="store-detail-package-panel-body">
|
||||||
|
{packages.length === 1 ? (
|
||||||
|
<Text className="store-detail-package-name">{activePackage.name}</Text>
|
||||||
|
) : null}
|
||||||
|
<Text className="store-detail-package-body">
|
||||||
|
{formatRedeemAmountYuan(activePackage.price)} 元 · {activePackage.dishes}
|
||||||
|
</Text>
|
||||||
|
{activePackage.usableTime ? (
|
||||||
|
<Text className="store-detail-package-meta">使用时间:{activePackage.usableTime}</Text>
|
||||||
|
) : null}
|
||||||
|
{activePackage.otherNotes ? (
|
||||||
|
<Text className="store-detail-package-meta">说明:{activePackage.otherNotes}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export default definePageConfig({
|
|
||||||
navigationStyle: 'custom',
|
|
||||||
navigationBarTitleText: '套餐详情',
|
|
||||||
});
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
import { useCallback, useState } from 'react';
|
|
||||||
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 = {
|
|
||||||
name: string;
|
|
||||||
price: string | number;
|
|
||||||
dishes: string;
|
|
||||||
usableTime?: string | null;
|
|
||||||
otherNotes?: string | null;
|
|
||||||
imageUrl?: string | null;
|
|
||||||
imageUrls?: string[] | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Store = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
packages?: StorePackage[] | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function pickStoreId(raw?: string | null) {
|
|
||||||
return String(raw || '')
|
|
||||||
.trim()
|
|
||||||
.replace(/[^\d]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePackageIndex(raw?: string | null) {
|
|
||||||
const n = Number.parseInt(String(raw ?? ''), 10);
|
|
||||||
return Number.isFinite(n) && n >= 0 ? n : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPriceYuan(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+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StorePackageDetailPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.storeId));
|
|
||||||
const [storeName, setStoreName] = useState('');
|
|
||||||
const [pkg, setPkg] = useState<StorePackage | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [loadError, setLoadError] = useState('');
|
|
||||||
|
|
||||||
const loadPackage = useCallback(async (sid: string, index: number) => {
|
|
||||||
if (!sid) {
|
|
||||||
setLoading(false);
|
|
||||||
setLoadError('缺少门店参数');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (index < 0) {
|
|
||||||
setLoading(false);
|
|
||||||
setLoadError('套餐不存在');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoadError('');
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await request<Store>(`/stores/${sid}`);
|
|
||||||
const packages = data?.packages ?? [];
|
|
||||||
const item = packages[index];
|
|
||||||
if (!data?.id || !item) {
|
|
||||||
setPkg(null);
|
|
||||||
setLoadError('套餐不存在或已下架');
|
|
||||||
toast('套餐不存在或已下架');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStoreName(data.name);
|
|
||||||
setPkg(item);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : '加载失败';
|
|
||||||
setLoadError(msg);
|
|
||||||
toast(msg);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useLoad((options) => {
|
|
||||||
const sid = pickStoreId(options?.storeId || router.params.storeId);
|
|
||||||
const index = parsePackageIndex(options?.index ?? router.params.index);
|
|
||||||
setStoreId(sid);
|
|
||||||
void loadPackage(sid, index);
|
|
||||||
});
|
|
||||||
|
|
||||||
function goBack() {
|
|
||||||
const pages = Taro.getCurrentPages();
|
|
||||||
if (pages.length > 1) Taro.navigateBack();
|
|
||||||
else if (storeId) {
|
|
||||||
Taro.redirectTo({ url: `/pages/store-detail/index?id=${storeId}` });
|
|
||||||
} else {
|
|
||||||
Taro.switchTab({ url: '/pages/stores/index' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<PageShell variant="scroll" className="store-package-detail-page">
|
|
||||||
<PageNavBar title="套餐详情" solid onBack={goBack} />
|
|
||||||
<View className="page-with-nav-bar u-empty">加载中…</View>
|
|
||||||
</PageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pkg) {
|
|
||||||
return (
|
|
||||||
<PageShell variant="scroll" className="store-package-detail-page">
|
|
||||||
<PageNavBar title="套餐详情" solid onBack={goBack} />
|
|
||||||
<View className="page-with-nav-bar u-empty">{loadError || '套餐不存在'}</View>
|
|
||||||
</PageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageUrls = normalizeStorePackageImageUrls(pkg);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell variant="scroll" className="store-package-detail-page">
|
|
||||||
<PageNavBar title={pkg.name} solid titleVisible onBack={goBack} />
|
|
||||||
|
|
||||||
<View className="store-package-detail-body">
|
|
||||||
<View className="store-package-detail-inner">
|
|
||||||
<View className="store-package-detail-header">
|
|
||||||
<View className="store-package-detail-title-row">
|
|
||||||
<Text className="store-package-detail-title">{pkg.name}</Text>
|
|
||||||
<Text className="store-package-detail-price">¥{formatPriceYuan(pkg.price)}</Text>
|
|
||||||
</View>
|
|
||||||
{storeName ? (
|
|
||||||
<Text className="store-package-detail-store">{storeName}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{imageUrls.length > 0 ? (
|
|
||||||
<View className="store-package-detail-gallery">
|
|
||||||
<ProductCarousel
|
|
||||||
images={imageUrls}
|
|
||||||
alt={pkg.name}
|
|
||||||
variant="detail"
|
|
||||||
previewable
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View className="store-package-detail-content">
|
|
||||||
<View className="store-detail-package-field">
|
|
||||||
<Text className="store-detail-package-field-label">菜品</Text>
|
|
||||||
<Text className="store-detail-package-field-value">{pkg.dishes || '—'}</Text>
|
|
||||||
</View>
|
|
||||||
{pkg.usableTime ? (
|
|
||||||
<View className="store-detail-package-field">
|
|
||||||
<Text className="store-detail-package-field-label">使用时间</Text>
|
|
||||||
<Text className="store-detail-package-field-value">{pkg.usableTime}</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{pkg.otherNotes ? (
|
|
||||||
<View className="store-detail-package-field">
|
|
||||||
<Text className="store-detail-package-field-label">其他说明</Text>
|
|
||||||
<Text className="store-detail-package-field-value">{pkg.otherNotes}</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</PageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { View, Text, Image, Input } from '@tarojs/components';
|
import { View, Text, Image, Input } from '@tarojs/components';
|
||||||
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||||
|
import { StoreStatus, STORE_STATUS_LABELS } from '@dukang/shared-types';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import TabMainHeader from '../../components/TabMainHeader';
|
import TabMainHeader from '../../components/TabMainHeader';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
@@ -36,11 +37,10 @@ import {
|
|||||||
setStoresListCache,
|
setStoresListCache,
|
||||||
} from '../../lib/stores-session';
|
} from '../../lib/stores-session';
|
||||||
import {
|
import {
|
||||||
buildSceneSharePayload,
|
DEFAULT_SHARE_DESC,
|
||||||
|
DEFAULT_SHARE_TITLE,
|
||||||
toWeappShareMessage,
|
toWeappShareMessage,
|
||||||
toWeappShareTimeline,
|
|
||||||
} from '../../lib/wechat-share';
|
} from '../../lib/wechat-share';
|
||||||
import openBadgeImg from '../../assets/icons/store-open-badge.png';
|
|
||||||
|
|
||||||
type Store = {
|
type Store = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -342,24 +342,33 @@ export default function StoresPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hoursText(store: Store): string {
|
function formatHours(store: Store) {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||||
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||||
if (!parts.length) parts.push('10:00-22:00');
|
return parts.length ? `营业时间: ${parts.join(',')}` : '营业时间: 10:00-22:00';
|
||||||
return `营业时间: ${parts.join(' ')}`;
|
}
|
||||||
|
|
||||||
|
function formatStatus(store: Store) {
|
||||||
|
const status = store.status as StoreStatus | undefined;
|
||||||
|
if (status && STORE_STATUS_LABELS[status]) return STORE_STATUS_LABELS[status];
|
||||||
|
return STORE_STATUS_LABELS[StoreStatus.OPEN];
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharePayload = useMemo(
|
const sharePayload = useMemo(
|
||||||
() =>
|
() => ({
|
||||||
buildSceneSharePayload('stores', {
|
title: '杜康好客门店',
|
||||||
|
desc: DEFAULT_SHARE_DESC,
|
||||||
path: '/pages/stores/index',
|
path: '/pages/stores/index',
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||||
useShareTimeline(() => toWeappShareTimeline(sharePayload));
|
useShareTimeline(() => ({
|
||||||
|
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||||
|
query: '',
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="tab" className="store-page no-tab-header">
|
<PageShell variant="tab" className="store-page no-tab-header">
|
||||||
@@ -422,38 +431,42 @@ export default function StoresPage() {
|
|||||||
className="store-card"
|
className="store-card"
|
||||||
onClick={() => Taro.navigateTo({ url: `/pages/store-detail/index?id=${s.id}` })}
|
onClick={() => Taro.navigateTo({ url: `/pages/store-detail/index?id=${s.id}` })}
|
||||||
>
|
>
|
||||||
<View className="store-card-cover-wrap">
|
|
||||||
{s.coverUrl ? (
|
{s.coverUrl ? (
|
||||||
<Image className="store-card-cover" src={s.coverUrl} mode="aspectFill" />
|
<Image className="store-card-cover" src={s.coverUrl} mode="aspectFill" />
|
||||||
) : (
|
) : (
|
||||||
<View className="store-card-cover store-card-cover--empty" />
|
<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">
|
<View className="store-card-body">
|
||||||
{/* 第1行:标题(截断无省略号,顶到最右) */}
|
{/* 第1行:标题 + 距离 */}
|
||||||
<View className="store-card-row store-card-row--head">
|
<View className="store-card-row store-card-row--head">
|
||||||
<Text className="store-card-name">{s.name}</Text>
|
<Text className="store-card-name" numberOfLines={1}>
|
||||||
</View>
|
{s.name}
|
||||||
{/* 第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>
|
||||||
<Text className="store-card-distance">
|
<Text className="store-card-distance">
|
||||||
{formatDistanceMeters(s.distanceMeters)}
|
{formatDistanceMeters(s.distanceMeters)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{/* 第3行:营业时间(同行) */}
|
{/* 第2行:状态 + 营业时间(含第二段) */}
|
||||||
<View className="store-card-row store-card-row--hours">
|
<View className="store-card-row store-card-row--meta">
|
||||||
<Text className="store-card-hours">{hoursText(s)}</Text>
|
<Text className="store-card-status">{formatStatus(s)}</Text>
|
||||||
|
<Text className="store-card-hours">{formatHours(s)}</Text>
|
||||||
|
</View>
|
||||||
|
{/* 第3行:地址 + 去核销 */}
|
||||||
|
<View className="store-card-row store-card-row--foot">
|
||||||
|
<Text className="store-card-address" numberOfLines={1}>
|
||||||
|
{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>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text className="store-card-arrow">›</Text>
|
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -386,20 +386,13 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
background: rgba(20, 16, 14, 0.45);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mine-profile-sheet-backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(20, 16, 14, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mine-profile-sheet {
|
.mine-profile-sheet {
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -429,9 +422,8 @@
|
|||||||
margin: 20px auto 0;
|
margin: 20px auto 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
background: transparent;
|
||||||
background: transparent !important;
|
border: none;
|
||||||
border: none !important;
|
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -442,11 +434,6 @@
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 子节点不抢触摸,保证 open-type=chooseAvatar 由 Button 本人响应 */
|
|
||||||
.mine-profile-avatar-btn > * {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mine-profile-avatar-preview {
|
.mine-profile-avatar-preview {
|
||||||
width: 88px;
|
width: 88px;
|
||||||
height: 88px;
|
height: 88px;
|
||||||
|
|||||||
@@ -12,22 +12,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4 / 3;
|
aspect-ratio: 4 / 3;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* background: var(--color-surface-container); */
|
background: var(--color-surface-container);
|
||||||
background-color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 门店门头:固定 4:3 区域,图片 aspectFit 缩放完整显示(不裁剪) */
|
|
||||||
.store-detail-carousel-wrap--contain .store-detail-carousel-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-detail-carousel-wrap--contain .store-detail-carousel-image {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
object-position: center center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-carousel {
|
.store-detail-carousel {
|
||||||
@@ -44,7 +29,6 @@
|
|||||||
|
|
||||||
.store-detail-carousel-image {
|
.store-detail-carousel-image {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
object-position: center center;
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +59,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-info-card {
|
.store-detail-info-card {
|
||||||
margin: 0 var(--space-page) 16px;
|
margin: -40px var(--space-page) 16px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
@@ -262,167 +246,100 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-package-list {
|
.store-detail-package-tabs {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-detail-package-list-item {
|
|
||||||
padding: 14px 0;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-detail-package-list-item:first-child {
|
|
||||||
padding-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-detail-package-list-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 名称在左可换行;价格始终贴该行最右侧 */
|
|
||||||
.store-detail-package-list-row {
|
|
||||||
/* display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: flex-start;
|
|
||||||
column-gap: 12px;
|
|
||||||
row-gap: 4px; */
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
margin-bottom: 10px;
|
||||||
|
|
||||||
.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 {
|
|
||||||
opacity: 0.72;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 套餐详情页(独立于门店详情) ── */
|
|
||||||
.store-package-detail-page {
|
|
||||||
background: var(--color-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-package-detail-body {
|
|
||||||
/* 避开状态栏+胶囊导航,并额外 12px 顶白 */
|
|
||||||
padding: calc(var(--nav-bar-height, 56px) + 12px) 0 24px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-package-detail-inner {
|
|
||||||
/* 左右留白,内容不贴边 */
|
|
||||||
padding: 8px 16px 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-package-detail-header {
|
|
||||||
padding: 8px 16px 4px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 与门店详情套餐列表一致:名称在左可换行;价格始终贴该行最右侧 */
|
|
||||||
.store-package-detail-title-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: flex-end;
|
|
||||||
column-gap: 16px;
|
|
||||||
row-gap: 4px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-package-detail-title {
|
|
||||||
/* flex: 1 1 10em; */
|
|
||||||
min-width: 0;
|
|
||||||
font-family: var(--font-headline);
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--color-ink-black);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-package-detail-price {
|
|
||||||
/* flex: 0 0 auto; */
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--color-heritage-red, #a61d24);
|
|
||||||
text-align: right;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-package-detail-store {
|
.store-detail-package-tabs-inner {
|
||||||
display: block;
|
display: inline-flex;
|
||||||
margin-top: 10px;
|
flex-wrap: nowrap;
|
||||||
font-size: 13px;
|
gap: 8px;
|
||||||
line-height: 1.5;
|
padding: 2px 0;
|
||||||
color: var(--color-on-surface-variant);
|
|
||||||
padding-left: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-package-detail-gallery {
|
.store-detail-package-tab {
|
||||||
margin: 14px 0 0;
|
display: inline-flex;
|
||||||
border-radius: var(--radius-lg);
|
align-items: center;
|
||||||
overflow: hidden;
|
flex-shrink: 0;
|
||||||
background: var(--color-card);
|
max-width: 132px;
|
||||||
box-shadow: var(--shadow-card);
|
padding: 6px 12px;
|
||||||
}
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
.store-package-detail-gallery .detail-carousel-wrap {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-package-detail-content {
|
|
||||||
margin-top: 16px;
|
|
||||||
background: var(--color-card);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 16px;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-package-field-label {
|
.store-detail-package-tab--active {
|
||||||
display: block;
|
background: rgba(166, 29, 36, 0.1);
|
||||||
font-size: 13px;
|
}
|
||||||
|
|
||||||
|
.store-detail-package-tab-text {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--color-text-secondary, #666);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-package-tab--active .store-detail-package-tab-text {
|
||||||
|
color: var(--color-heritage-red, #a61d24);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-on-surface-variant);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-package-field-value {
|
.store-detail-package-panel {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
background: var(--color-surface-container, #f7f7f7);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-package-thumb {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-package-panel-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-package-body,
|
||||||
|
.store-detail-package-meta {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-detail-package-name {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
color: var(--color-on-surface);
|
font-weight: 700;
|
||||||
line-height: 1.7;
|
color: var(--color-text-primary, #1a1a1a);
|
||||||
white-space: pre-wrap;
|
margin-bottom: 4px;
|
||||||
word-break: break-word;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-package-field {
|
.store-detail-package-body {
|
||||||
margin-bottom: 16px;
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary, #666);
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-package-field:last-child {
|
.store-detail-package-meta {
|
||||||
margin-bottom: 0;
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary, #999);
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-package-dispute {
|
.store-detail-package-dispute {
|
||||||
|
|||||||
@@ -144,12 +144,12 @@
|
|||||||
padding: 4px var(--space-page) 16px;
|
padding: 4px var(--space-page) 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 左图 + 中间文案 + 右侧箭头 */
|
/* 左图右文,卡片等高 */
|
||||||
.store-card {
|
.store-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
@@ -158,19 +158,11 @@
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-card-cover-wrap {
|
.store-card-cover {
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 96px;
|
width: 96px;
|
||||||
height: 96px;
|
height: 96px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-card-cover {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--color-surface-container);
|
background: var(--color-surface-container);
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -179,42 +171,31 @@
|
|||||||
background: var(--color-surface-container);
|
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 {
|
.store-card-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 96px;
|
height: 96px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 4px;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-card-row {
|
.store-card-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 第1行:加粗标题(单行截断,不显示 …,宽度顶到最右) */
|
/* 第1行:加粗标题(单行截断)+ 右对齐距离 */
|
||||||
.store-card-row--head {
|
.store-card-row--head {
|
||||||
|
gap: 8px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-card-name {
|
.store-card-name {
|
||||||
width: 100%;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-family: var(--font-headline);
|
font-family: var(--font-headline);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -222,70 +203,84 @@
|
|||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: clip;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 第2行:地址最多两行 + 右对齐距离 */
|
|
||||||
.store-card-row--mid {
|
|
||||||
gap: 8px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-card-address {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 16px;
|
|
||||||
max-height: 32px;
|
|
||||||
color: #999;
|
|
||||||
text-align: left;
|
|
||||||
white-space: normal;
|
|
||||||
word-break: break-word;
|
|
||||||
overflow: hidden;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
line-clamp: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-card-distance {
|
.store-card-distance {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
max-width: 40%;
|
max-width: 40%;
|
||||||
padding-top: 1px;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 16px;
|
line-height: 22px;
|
||||||
color: #999;
|
color: #999;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 第3行:营业时间同行 */
|
/* 第2行:营业状态 + 营业时间(可含两段) */
|
||||||
.store-card-row--hours {
|
.store-card-row--meta {
|
||||||
align-items: center;
|
gap: 6px;
|
||||||
|
min-height: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-card-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 6px;
|
||||||
|
margin-top: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(45, 106, 79, 0.12);
|
||||||
|
color: #2d6a4f;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-card-hours {
|
.store-card-hours {
|
||||||
width: 100%;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 第3行:地址(单行截断)+ 右对齐去核销 */
|
||||||
|
.store-card-row--foot {
|
||||||
|
gap: 8px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-card-address {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 28px;
|
||||||
|
color: #999;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: clip;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-card-arrow {
|
.store-card-cta {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-self: center;
|
display: flex;
|
||||||
width: 16px;
|
align-items: center;
|
||||||
font-size: 20px;
|
justify-content: center;
|
||||||
font-weight: 300;
|
padding: 0 14px;
|
||||||
line-height: 1;
|
height: 26px;
|
||||||
color: #ccc;
|
border-radius: 999px;
|
||||||
text-align: center;
|
background: var(--color-heritage-red, #a61d24);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-card-cta-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 26px;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# sync-prod-db-to-local.sh — 将线上(production)数据库同步到本地 Docker MySQL
|
|
||||||
#
|
|
||||||
# 原理:
|
|
||||||
# 1. 通过 SSH 隧道穿透到阿里云 RDS。生产 DB 主机通常只允许应用服务器访问,
|
|
||||||
# 本地开发机一般不在白名单,因此借道生产服务器 dukang-server 建立隧道。
|
|
||||||
# 2. 在本地 dukang-v1-mysql 容器内用 mysqldump 连接隧道端口,导出线上库。
|
|
||||||
# 3. 通过管道直接导入本地库(默认 dukang_haoke,即应用本地库名)。
|
|
||||||
#
|
|
||||||
# 前置条件:
|
|
||||||
# - deploy.env 中 DEPLOY_HOST / DEPLOY_USER / DEPLOY_PORT 已配置且可无密码 SSH
|
|
||||||
# - 本地 docker compose 已启动(dukang-v1-mysql 容器监听 6016)
|
|
||||||
# - 远端 /opt/dukang/server/dukang-api/.env.production 含 DATABASE_URL
|
|
||||||
# - 本地已安装 docker(Windows 用 Docker Desktop,容器内自带 mysql 客户端)
|
|
||||||
#
|
|
||||||
# 用法:
|
|
||||||
# bash deploy/sync-prod-db-to-local.sh # 交互确认 + 先备份本地
|
|
||||||
# bash deploy/sync-prod-db-to-local.sh --yes # 跳过确认(仍先备份本地)
|
|
||||||
# bash deploy/sync-prod-db-to-local.sh --no-backup --yes
|
|
||||||
# bash deploy/sync-prod-db-to-local.sh --dry-run # 仅打印计划,不落库、不开隧道
|
|
||||||
# bash deploy/sync-prod-db-to-local.sh --help
|
|
||||||
#
|
|
||||||
set -euo pipefail
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
# ---------------- 默认参数 ----------------
|
|
||||||
LOCAL_CONTAINER="dukang-v1-mysql"
|
|
||||||
LOCAL_DB="dukang_haoke"
|
|
||||||
LOCAL_ROOT_PASSWORD="root"
|
|
||||||
TUNNEL_PORT=6018
|
|
||||||
REMOTE_ENV_FILE="/opt/dukang/server/dukang-api/.env.production"
|
|
||||||
HOST_ALIAS="host.docker.internal" # 容器内访问宿主机(Docker Desktop 默认支持)
|
|
||||||
DO_BACKUP=1
|
|
||||||
ASSUME_YES=0
|
|
||||||
DRY_RUN=0
|
|
||||||
|
|
||||||
# ---------------- 解析参数 ----------------
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
|
||||||
--yes|-y) ASSUME_YES=1 ;;
|
|
||||||
--no-backup) DO_BACKUP=0 ;;
|
|
||||||
--dry-run) DRY_RUN=1 ;;
|
|
||||||
--help|-h) sed -n '3,30p' "$0"; exit 0 ;;
|
|
||||||
--tunnel-port=*) TUNNEL_PORT="${arg#*=}" ;;
|
|
||||||
--local-db=*) LOCAL_DB="${arg#*=}" ;;
|
|
||||||
--container=*) LOCAL_CONTAINER="${arg#*=}" ;;
|
|
||||||
--host-alias=*) HOST_ALIAS="${arg#*=}" ;;
|
|
||||||
*) echo "未知参数: $arg" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# ---------------- 加载 deploy.env ----------------
|
|
||||||
if [[ -f deploy.env ]]; then
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
source deploy.env
|
|
||||||
fi
|
|
||||||
DEPLOY_HOST="${DEPLOY_HOST:?请在 deploy.env 配置 DEPLOY_HOST}"
|
|
||||||
DEPLOY_USER="${DEPLOY_USER:-root}"
|
|
||||||
DEPLOY_PORT="${DEPLOY_PORT:-22}"
|
|
||||||
SSH=(ssh -o StrictHostKeyChecking=accept-new -p "$DEPLOY_PORT")
|
|
||||||
if [[ -n "${DEPLOY_SSH_KEY:-}" ]]; then SSH+=(-i "$DEPLOY_SSH_KEY"); fi
|
|
||||||
TARGET="$DEPLOY_USER@$DEPLOY_HOST"
|
|
||||||
|
|
||||||
# ---------------- 读取并解析远端 DATABASE_URL ----------------
|
|
||||||
echo "==> 读取线上数据库配置 ($TARGET:$REMOTE_ENV_FILE)"
|
|
||||||
# 用服务端 new URL 解析(密码可能含 @ / : 等特殊字符),base64 回传避免 shell 转义问题
|
|
||||||
PARSED="$("${SSH[@]}" "$TARGET" bash -s <<'NODEEOF'
|
|
||||||
cat > /tmp/_parse_db.js <<'JSEOF'
|
|
||||||
const fs=require("fs");
|
|
||||||
const l=fs.readFileSync("/opt/dukang/server/dukang-api/.env.production","utf8").match(/^DATABASE_URL=(.*)$/m)[1].trim();
|
|
||||||
let u=l;
|
|
||||||
if((u[0]==='"'&&u[u.length-1]==='"')||(u[0]==="'"&&u[u.length-1]==="'"))u=u.slice(1,-1);
|
|
||||||
const U=new URL(u);
|
|
||||||
const b=s=>Buffer.from(s).toString("base64");
|
|
||||||
const path=U.pathname.replace(/^\//,"").split("?")[0];
|
|
||||||
process.stdout.write([b(decodeURIComponent(U.username)),b(U.hostname),U.port||"3306",b(path),b(decodeURIComponent(U.password))].join("|")+"\n");
|
|
||||||
JSEOF
|
|
||||||
node /tmp/_parse_db.js; rm -f /tmp/_parse_db.js
|
|
||||||
NODEEOF
|
|
||||||
)"
|
|
||||||
if [[ -z "$PARSED" ]]; then
|
|
||||||
echo "无法读取远端 DATABASE_URL,中止。" >&2; exit 1
|
|
||||||
fi
|
|
||||||
IFS='|' read -r _U _H _P _DB _PASS <<< "$PARSED"
|
|
||||||
REMOTE_USER="$(echo "$_U" | base64 -d)"
|
|
||||||
REMOTE_HOST="$(echo "$_H" | base64 -d)"
|
|
||||||
REMOTE_PORT="$_P"
|
|
||||||
REMOTE_DB="$(echo "$_DB" | base64 -d)"
|
|
||||||
REMOTE_PASS="$(echo "$_PASS" | base64 -d)"
|
|
||||||
echo " 线上库: $REMOTE_USER@$REMOTE_HOST:$REMOTE_PORT/$REMOTE_DB"
|
|
||||||
|
|
||||||
# ---------------- 检查本地容器 ----------------
|
|
||||||
if ! docker ps --format '{{.Names}}' | grep -qx "$LOCAL_CONTAINER"; then
|
|
||||||
echo "本地容器 $LOCAL_CONTAINER 未运行。请先执行: cd deploy && docker compose up -d" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------- 打印计划 ----------------
|
|
||||||
echo
|
|
||||||
echo "同步计划:"
|
|
||||||
echo " 源(线上): $REMOTE_USER@$REMOTE_HOST:$REMOTE_PORT/$REMOTE_DB"
|
|
||||||
echo " 目标(本地): root@$LOCAL_CONTAINER:$LOCAL_DB (对外端口 6016)"
|
|
||||||
echo " 隧道: $TARGET -L $TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT"
|
|
||||||
echo " 本地备份: $([[ $DO_BACKUP -eq 1 ]] && echo 是 || echo 否)"
|
|
||||||
if [[ $DRY_RUN -eq 1 ]]; then
|
|
||||||
echo "(dry-run) 已结束,未做任何修改。"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------- 确认 ----------------
|
|
||||||
if [[ $ASSUME_YES -ne 1 ]]; then
|
|
||||||
read -r -p "确认将【线上 $REMOTE_DB】覆盖同步到【本地 $LOCAL_DB】? [y/N] " ans
|
|
||||||
[[ "$ans" == "y" || "$ans" == "Y" ]] || { echo "已取消。"; exit 0; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------- 备份本地 ----------------
|
|
||||||
if [[ $DO_BACKUP -eq 1 ]]; then
|
|
||||||
mkdir -p backups
|
|
||||||
BK="backups/local-${LOCAL_DB}-$(date +%Y%m%d-%H%M%S).sql"
|
|
||||||
echo "==> 备份本地库到 $BK"
|
|
||||||
docker exec "$LOCAL_CONTAINER" mysqldump -uroot -p"$LOCAL_ROOT_PASSWORD" --single-transaction "$LOCAL_DB" > "$BK"
|
|
||||||
echo " 备份完成 ($(wc -c < "$BK") bytes)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------- 确保本地目标库存在 ----------------
|
|
||||||
docker exec "$LOCAL_CONTAINER" mysql -uroot -p"$LOCAL_ROOT_PASSWORD" \
|
|
||||||
-e "CREATE DATABASE IF NOT EXISTS \`$LOCAL_DB\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
|
||||||
|
|
||||||
# ---------------- 建立 SSH 隧道 ----------------
|
|
||||||
CTL="/tmp/sync-prod-db-$$.sock"
|
|
||||||
echo "==> 建立 SSH 隧道 $TARGET -L $TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT"
|
|
||||||
"${SSH[@]}" -M -S "$CTL" -f -N -L "$TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" "$TARGET"
|
|
||||||
|
|
||||||
# 等待隧道就绪
|
|
||||||
ready=0
|
|
||||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
|
||||||
if docker exec -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \
|
|
||||||
mysqladmin -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" ping >/dev/null 2>&1; then
|
|
||||||
ready=1; break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
if [[ $ready -ne 1 ]]; then
|
|
||||||
echo "隧道未就绪,中止。" >&2
|
|
||||||
"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------- 同步(线上 -> 本地,管道直导) ----------------
|
|
||||||
echo "==> 开始同步(管道直导,不落临时文件)"
|
|
||||||
echo " 提示:同步前建议本地 Prisma 已追平线上 schema(pnpm db:push 或迁移),否则可能因表结构差异报错。"
|
|
||||||
set +e
|
|
||||||
docker exec -i -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \
|
|
||||||
mysqldump -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" \
|
|
||||||
--lock-tables=0 --add-drop-table --skip-triggers --no-create-db \
|
|
||||||
--skip-routines --skip-events --column-statistics=0 --no-tablespaces --set-gtid-purged=OFF \
|
|
||||||
"$REMOTE_DB" \
|
|
||||||
| docker exec -i "$LOCAL_CONTAINER" mysql -uroot -p"$LOCAL_ROOT_PASSWORD" "$LOCAL_DB"
|
|
||||||
RC=${PIPESTATUS[0]}
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# ---------------- 关闭隧道 ----------------
|
|
||||||
"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true
|
|
||||||
|
|
||||||
if [[ $RC -ne 0 ]]; then
|
|
||||||
echo "同步失败 (mysqldump 退出码 $RC)。本地库可能处于不一致状态。" >&2
|
|
||||||
[[ $DO_BACKUP -eq 1 ]] && echo "请用备份恢复: $BK" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "==> 同步完成 ✅ 本地 $LOCAL_DB 现已是线上 $REMOTE_DB 的副本。"
|
|
||||||
@@ -4,14 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./src/index.ts",
|
|
||||||
"require": "./dist/index.js",
|
|
||||||
"default": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsc --watch",
|
"dev": "tsc --watch",
|
||||||
|
|||||||
@@ -351,4 +351,3 @@ export function orderTabToStatuses(tab: string): string[] | undefined {
|
|||||||
export * from './city-partner';
|
export * from './city-partner';
|
||||||
export * from './dev-plan';
|
export * from './dev-plan';
|
||||||
export * from './support-ticket';
|
export * from './support-ticket';
|
||||||
export * from './phone';
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
isLandlinePhone,
|
|
||||||
isMobilePhone,
|
|
||||||
isStoreContactPhone,
|
|
||||||
maskContactPhone,
|
|
||||||
normalizeContactPhone,
|
|
||||||
toDialablePhone,
|
|
||||||
} from './phone';
|
|
||||||
|
|
||||||
describe('store contact phone', () => {
|
|
||||||
it('accepts mainland mobile numbers', () => {
|
|
||||||
expect(isMobilePhone('13800138000')).toBe(true);
|
|
||||||
expect(isStoreContactPhone('13800138000')).toBe(true);
|
|
||||||
expect(isStoreContactPhone(' 13800138000 ')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts landlines with or without hyphens and optional extension', () => {
|
|
||||||
expect(isLandlinePhone('0379-8888888')).toBe(true);
|
|
||||||
expect(isLandlinePhone('010-12345678')).toBe(true);
|
|
||||||
expect(isLandlinePhone('03798888888')).toBe(true);
|
|
||||||
expect(isLandlinePhone('01012345678')).toBe(true);
|
|
||||||
expect(isLandlinePhone('0379-8888888-12')).toBe(true);
|
|
||||||
expect(isStoreContactPhone('0379-8888 888')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects login-style invalid and incomplete numbers', () => {
|
|
||||||
expect(isStoreContactPhone('')).toBe(false);
|
|
||||||
expect(isStoreContactPhone('12345678')).toBe(false);
|
|
||||||
expect(isStoreContactPhone('12345678901')).toBe(false);
|
|
||||||
expect(isStoreContactPhone('400-123-4567')).toBe(false);
|
|
||||||
expect(isMobilePhone('0379-8888888')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes spaces and strips dial punctuation', () => {
|
|
||||||
expect(normalizeContactPhone(' 0379-8888 888 ')).toBe('0379-8888888');
|
|
||||||
expect(toDialablePhone('0379-8888888')).toBe('03798888888');
|
|
||||||
expect(toDialablePhone('010 1234 5678')).toBe('01012345678');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('masks mobile and landline for display', () => {
|
|
||||||
expect(maskContactPhone('13800138000')).toBe('138****8000');
|
|
||||||
expect(maskContactPhone('0379-8888888')).toBe('0379-****888');
|
|
||||||
expect(maskContactPhone('010-12345678')).toBe('010-****5678');
|
|
||||||
expect(maskContactPhone('03798888888')).toBe('0379****888');
|
|
||||||
expect(maskContactPhone('0379-8888888-12')).toBe('0379-****888-12');
|
|
||||||
expect(maskContactPhone('')).toBe('—');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/** 11 位大陆手机号(登录凭证) */
|
|
||||||
export const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 国内座机:区号 0 开头(3~4 位),本地号 7~8 位,允许 `-`,可选分机。
|
|
||||||
* 例:0379-8888888、010-12345678、03798888888
|
|
||||||
*/
|
|
||||||
export const LANDLINE_PHONE_RE = /^0\d{2,3}-?\d{7,8}(-\d{1,6})?$/;
|
|
||||||
|
|
||||||
export const STORE_CONTACT_PHONE_HINT = '请输入正确的联系电话(手机号或座机,如 0379-8888888)';
|
|
||||||
|
|
||||||
export function normalizeContactPhone(raw: string): string {
|
|
||||||
return String(raw ?? '')
|
|
||||||
.trim()
|
|
||||||
.replace(/\s+/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isMobilePhone(raw: string): boolean {
|
|
||||||
return MOBILE_PHONE_RE.test(normalizeContactPhone(raw));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLandlinePhone(raw: string): boolean {
|
|
||||||
return LANDLINE_PHONE_RE.test(normalizeContactPhone(raw));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 门店对外联系电话:手机号或座机 */
|
|
||||||
export function isStoreContactPhone(raw: string): boolean {
|
|
||||||
const s = normalizeContactPhone(raw);
|
|
||||||
return isMobilePhone(s) || isLandlinePhone(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 拨号用:去掉空格和横线 */
|
|
||||||
export function toDialablePhone(raw: string): string {
|
|
||||||
return String(raw ?? '').replace(/[\s-]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对外联系电话脱敏展示。
|
|
||||||
* - 手机:138****8000
|
|
||||||
* - 座机:保留区号,本地号中间打码,如 0379-****888 / 010-****5678
|
|
||||||
* - 带分机时保留分机后缀
|
|
||||||
*/
|
|
||||||
export function maskContactPhone(phone?: string | null): string {
|
|
||||||
const raw = String(phone ?? '').trim();
|
|
||||||
if (!raw) return '—';
|
|
||||||
const normalized = normalizeContactPhone(raw);
|
|
||||||
if (isMobilePhone(normalized)) {
|
|
||||||
const digits = normalized.replace(/\D/g, '');
|
|
||||||
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
|
||||||
}
|
|
||||||
if (isLandlinePhone(normalized)) {
|
|
||||||
const extMatch = normalized.match(/-(\d{1,6})$/);
|
|
||||||
const hasExt = !!extMatch && normalized.indexOf('-') !== normalized.lastIndexOf('-');
|
|
||||||
const ext = hasExt ? extMatch![1] : '';
|
|
||||||
const main = hasExt ? normalized.slice(0, -(ext.length + 1)) : normalized;
|
|
||||||
const digits = main.replace(/\D/g, '');
|
|
||||||
const areaLen = digits.startsWith('01') || digits.startsWith('02') ? 3 : 4;
|
|
||||||
const area = digits.slice(0, areaLen);
|
|
||||||
const local = digits.slice(areaLen);
|
|
||||||
const keepTail = Math.min(4, Math.max(2, local.length - 4));
|
|
||||||
const maskedLocal =
|
|
||||||
local.length <= 4 ? '*'.repeat(local.length) : `${'*'.repeat(local.length - keepTail)}${local.slice(-keepTail)}`;
|
|
||||||
const joiner = main.includes('-') ? '-' : '';
|
|
||||||
return ext ? `${area}${joiner}${maskedLocal}-${ext}` : `${area}${joiner}${maskedLocal}`;
|
|
||||||
}
|
|
||||||
const digits = normalized.replace(/\D/g, '');
|
|
||||||
if (digits.length >= 11) return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
|
||||||
if (digits.length >= 7) return `${digits.slice(0, 3)}****${digits.slice(-2)}`;
|
|
||||||
if (digits.length > 0) return `${digits.slice(0, 1)}****`;
|
|
||||||
return '****';
|
|
||||||
}
|
|
||||||
@@ -32,29 +32,29 @@ export interface AppConfig {
|
|||||||
userH5Url: string;
|
userH5Url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 推广码 / C 端 H5 默认落地页(系统设置 USER_H5_URL 未配时回退;HQ 可改) */
|
/** 推广码 / C 端 H5 默认落地页(未配置 USER_H5_URL 时使用) */
|
||||||
export const DEFAULT_USER_H5_URL = 'https://user.runxian.top/user';
|
export const DEFAULT_USER_H5_URL = 'https://user.runxian.top/user';
|
||||||
|
|
||||||
/** 品牌 Logo OSS 根路径(默认;系统设置 BRAND_LOGO_OSS_BASE 可覆盖) */
|
/** 品牌 Logo OSS 根路径(改环境时只改此处) */
|
||||||
export const BRAND_LOGO_OSS_BASE = 'https://dukang-dev.oss-cn-beijing.aliyuncs.com/logo/';
|
export const BRAND_LOGO_OSS_BASE = 'https://dukang-dev.oss-cn-beijing.aliyuncs.com/logo/';
|
||||||
|
|
||||||
/** 方形 Logo(默认;系统设置 BRAND_LOGO_URL 可覆盖) */
|
/** 方形 Logo(首页等品牌展示;商品列表顶栏仍用文字标题) */
|
||||||
export const BRAND_LOGO_URL = `${BRAND_LOGO_OSS_BASE}logo.png`;
|
export const BRAND_LOGO_URL = `${BRAND_LOGO_OSS_BASE}logo.png`;
|
||||||
|
|
||||||
/** 长方形 Logo(默认;系统设置 BRAND_LOGO_WIDE_URL 可覆盖) */
|
/** 长方形 Logo(含文字,登录等场景) */
|
||||||
export const BRAND_LOGO_WIDE_URL = `${BRAND_LOGO_OSS_BASE}logo1.png`;
|
export const BRAND_LOGO_WIDE_URL = `${BRAND_LOGO_OSS_BASE}logo1.png`;
|
||||||
|
|
||||||
/** 仅图标 Logo(默认;系统设置 BRAND_LOGO_MARK_URL 可覆盖) */
|
/** 仅图标 Logo(默认头像:未微信授权时) */
|
||||||
export const BRAND_LOGO_MARK_URL = `${BRAND_LOGO_OSS_BASE}logo2.png`;
|
export const BRAND_LOGO_MARK_URL = `${BRAND_LOGO_OSS_BASE}logo2.png`;
|
||||||
|
|
||||||
/** 小程序静态资源根路径(默认;系统设置 MINI_USER_STATIC_OSS_BASE 可覆盖) */
|
/** 小程序静态资源(资质公示等) */
|
||||||
export const MINI_USER_STATIC_OSS_BASE =
|
export const MINI_USER_STATIC_OSS_BASE =
|
||||||
'https://dukang-dev.oss-cn-beijing.aliyuncs.com/static/mini-user/';
|
'https://dukang-dev.oss-cn-beijing.aliyuncs.com/static/mini-user/';
|
||||||
|
|
||||||
/** 「我的」页资质公示长图(默认;系统设置 QUALIFICATION_DISCLOSURE_URL 可覆盖) */
|
/** 「我的」页资质公示长图 */
|
||||||
export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualification-disclosure.png`;
|
export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualification-disclosure.png`;
|
||||||
|
|
||||||
/** 总部客服电话(默认;系统设置 CUSTOMER_SERVICE_PHONE 可覆盖) */
|
/** 总部客服电话(C 端联系客服) */
|
||||||
export const CUSTOMER_SERVICE_PHONE = '13203801799';
|
export const CUSTOMER_SERVICE_PHONE = '13203801799';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,137 +64,6 @@ export const CUSTOMER_SERVICE_PHONE = '13203801799';
|
|||||||
export const CUSTOMER_SERVICE_WECOM_URL =
|
export const CUSTOMER_SERVICE_WECOM_URL =
|
||||||
'https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd';
|
'https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd';
|
||||||
|
|
||||||
/** 从 env / 系统设置解析的 C 端品牌与客服展示配置(缺省回退常量) */
|
|
||||||
export type ClientBrandRuntime = {
|
|
||||||
userH5Url: string;
|
|
||||||
brandLogoOssBase: string;
|
|
||||||
brandLogoUrl: string;
|
|
||||||
brandLogoWideUrl: string;
|
|
||||||
brandLogoMarkUrl: string;
|
|
||||||
miniUserStaticOssBase: string;
|
|
||||||
qualificationDisclosureUrl: string;
|
|
||||||
customerServicePhone: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function resolveClientBrandRuntime(
|
|
||||||
env?: Record<string, string | undefined>,
|
|
||||||
): ClientBrandRuntime {
|
|
||||||
const e = readEnv(env);
|
|
||||||
const brandBase = (e.BRAND_LOGO_OSS_BASE || BRAND_LOGO_OSS_BASE).trim().replace(/\/*$/, '/');
|
|
||||||
const staticBase = (e.MINI_USER_STATIC_OSS_BASE || MINI_USER_STATIC_OSS_BASE)
|
|
||||||
.trim()
|
|
||||||
.replace(/\/*$/, '/');
|
|
||||||
return {
|
|
||||||
userH5Url: (e.USER_H5_URL || DEFAULT_USER_H5_URL).replace(/\/$/, ''),
|
|
||||||
brandLogoOssBase: brandBase,
|
|
||||||
brandLogoUrl: (e.BRAND_LOGO_URL || '').trim() || `${brandBase}logo.png`,
|
|
||||||
brandLogoWideUrl: (e.BRAND_LOGO_WIDE_URL || '').trim() || `${brandBase}logo1.png`,
|
|
||||||
brandLogoMarkUrl: (e.BRAND_LOGO_MARK_URL || '').trim() || `${brandBase}logo2.png`,
|
|
||||||
miniUserStaticOssBase: staticBase,
|
|
||||||
qualificationDisclosureUrl:
|
|
||||||
(e.QUALIFICATION_DISCLOSURE_URL || '').trim() ||
|
|
||||||
`${staticBase}qualification-disclosure.png`,
|
|
||||||
customerServicePhone: (e.CUSTOMER_SERVICE_PHONE || CUSTOMER_SERVICE_PHONE).trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mock 短信固定验证码(系统设置 MOCK_SMS_FIXED_CODE 可覆盖) */
|
|
||||||
export function resolveMockSmsFixedCode(env?: Record<string, string | undefined>): string {
|
|
||||||
const code = (readEnv(env).MOCK_SMS_FIXED_CODE || '').trim();
|
|
||||||
return code || MOCK_SMS_FIXED_CODE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 小程序 / H5 默认分享文案与引导(系统设置「小程序分享配置」可覆盖) */
|
|
||||||
export const DEFAULT_SHARE_TITLE = '你吃饭,我买单';
|
|
||||||
export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用';
|
|
||||||
export const DEFAULT_SHARE_HINT = '请点击右上角 ··· 分享给好友';
|
|
||||||
export const DEFAULT_SHARE_STORES_TITLE = '杜康好客门店';
|
|
||||||
export const DEFAULT_SHARE_BENEFIT_TITLE = '好客权益 · 杜康好客';
|
|
||||||
export const DEFAULT_SHARE_MINE_TITLE = '杜康好客 · 我的';
|
|
||||||
/** 订单详情分享标题模板,可用 {productName} */
|
|
||||||
export const DEFAULT_SHARE_ORDER_TITLE = '我买了{productName} · 杜康好客';
|
|
||||||
|
|
||||||
export type MiniShareSceneConfig = {
|
|
||||||
title: string;
|
|
||||||
desc: string;
|
|
||||||
imageUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 各场景分享文案/图(空字符串表示该字段走动态内容或默认分享) */
|
|
||||||
export type MiniShareRuntime = {
|
|
||||||
hint: string;
|
|
||||||
default: MiniShareSceneConfig;
|
|
||||||
home: MiniShareSceneConfig;
|
|
||||||
stores: MiniShareSceneConfig;
|
|
||||||
storeDetail: MiniShareSceneConfig;
|
|
||||||
benefit: MiniShareSceneConfig;
|
|
||||||
mine: MiniShareSceneConfig;
|
|
||||||
productDetail: MiniShareSceneConfig;
|
|
||||||
orderDetail: MiniShareSceneConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
function pickShareScene(
|
|
||||||
e: Record<string, string | undefined>,
|
|
||||||
prefix: string,
|
|
||||||
fallback: Partial<MiniShareSceneConfig> = {},
|
|
||||||
): MiniShareSceneConfig {
|
|
||||||
return {
|
|
||||||
title: (e[`${prefix}_TITLE`] || '').trim() || fallback.title || '',
|
|
||||||
desc: (e[`${prefix}_DESC`] || '').trim() || fallback.desc || '',
|
|
||||||
imageUrl: (e[`${prefix}_IMAGE_URL`] || '').trim() || fallback.imageUrl || '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从 env / 系统设置解析小程序分享配置 */
|
|
||||||
export function resolveMiniShareRuntime(
|
|
||||||
env?: Record<string, string | undefined>,
|
|
||||||
): MiniShareRuntime {
|
|
||||||
const e = readEnv(env);
|
|
||||||
const brand = resolveClientBrandRuntime(e);
|
|
||||||
const defaults: MiniShareSceneConfig = {
|
|
||||||
title: (e.SHARE_DEFAULT_TITLE || '').trim() || DEFAULT_SHARE_TITLE,
|
|
||||||
desc: (e.SHARE_DEFAULT_DESC || '').trim() || DEFAULT_SHARE_DESC,
|
|
||||||
imageUrl: (e.SHARE_DEFAULT_IMAGE_URL || '').trim() || brand.brandLogoUrl,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
hint: (e.SHARE_HINT || '').trim() || DEFAULT_SHARE_HINT,
|
|
||||||
default: defaults,
|
|
||||||
home: pickShareScene(e, 'SHARE_HOME', {
|
|
||||||
title: defaults.title,
|
|
||||||
desc: defaults.desc,
|
|
||||||
}),
|
|
||||||
stores: pickShareScene(e, 'SHARE_STORES', {
|
|
||||||
title: DEFAULT_SHARE_STORES_TITLE,
|
|
||||||
desc: defaults.desc,
|
|
||||||
}),
|
|
||||||
storeDetail: pickShareScene(e, 'SHARE_STORE_DETAIL'),
|
|
||||||
benefit: pickShareScene(e, 'SHARE_BENEFIT', {
|
|
||||||
title: DEFAULT_SHARE_BENEFIT_TITLE,
|
|
||||||
desc: defaults.desc,
|
|
||||||
}),
|
|
||||||
mine: pickShareScene(e, 'SHARE_MINE', {
|
|
||||||
title: DEFAULT_SHARE_MINE_TITLE,
|
|
||||||
desc: defaults.desc,
|
|
||||||
}),
|
|
||||||
productDetail: pickShareScene(e, 'SHARE_PRODUCT_DETAIL'),
|
|
||||||
orderDetail: pickShareScene(e, 'SHARE_ORDER_DETAIL', {
|
|
||||||
title: DEFAULT_SHARE_ORDER_TITLE,
|
|
||||||
desc: defaults.desc,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 订单等标题模板:替换 {productName} 等占位符 */
|
|
||||||
export function applyShareTitleTemplate(
|
|
||||||
template: string,
|
|
||||||
vars: Record<string, string | undefined | null>,
|
|
||||||
): string {
|
|
||||||
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
|
||||||
const v = vars[key];
|
|
||||||
return v != null && String(v).trim() ? String(v).trim() : '';
|
|
||||||
}).replace(/\s{2,}/g, ' ').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function readEnv(env?: Record<string, string | undefined>) {
|
function readEnv(env?: Record<string, string | undefined>) {
|
||||||
return (
|
return (
|
||||||
env ??
|
env ??
|
||||||
@@ -273,5 +142,5 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mock 短信环境固定验证码(默认;系统设置 MOCK_SMS_FIXED_CODE 可覆盖) */
|
/** Mock 短信环境固定验证码 */
|
||||||
export const MOCK_SMS_FIXED_CODE = '999888';
|
export const MOCK_SMS_FIXED_CODE = '999888';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** HQ 权限目录(权限分配页勾选源) */
|
/** HQ 权限目录(权限分配页勾选源) */
|
||||||
export const HQ_PERMISSION_CATALOG = [
|
export const HQ_PERMISSION_CATALOG = [
|
||||||
{ key: 'dashboard', label: '概览', group: '业务' },
|
{ key: 'dashboard', label: '概览', group: '业务' },
|
||||||
{ key: 'users', label: '用户管理', group: '业务' },
|
{ key: 'users', label: '用户管理', group: '业务' },
|
||||||
@@ -18,7 +18,6 @@ export const HQ_PERMISSION_CATALOG = [
|
|||||||
{ key: 'llm_configs', label: '语言模型配置', group: '业务' },
|
{ key: 'llm_configs', label: '语言模型配置', group: '业务' },
|
||||||
{ key: 'knowledge_bases', label: '知识库', group: '业务' },
|
{ key: 'knowledge_bases', label: '知识库', group: '业务' },
|
||||||
{ key: 'dev_plan', label: '开发计划', group: '业务' },
|
{ key: 'dev_plan', label: '开发计划', group: '业务' },
|
||||||
{ key: 'test_whitelist', label: '白名单管理', group: '业务' },
|
|
||||||
{ key: 'resources', label: 'OSS 资源库', group: '业务' },
|
{ key: 'resources', label: 'OSS 资源库', group: '业务' },
|
||||||
{ key: 'logs', label: '日志', group: '业务' },
|
{ key: 'logs', label: '日志', group: '业务' },
|
||||||
{ key: 'users_delete', label: '删除用户', group: '危险操作' },
|
{ key: 'users_delete', label: '删除用户', group: '危险操作' },
|
||||||
@@ -30,7 +29,6 @@ export const HQ_PERMISSION_CATALOG = [
|
|||||||
{ key: 'system_settings_sms', label: '短信', group: '系统设置' },
|
{ key: 'system_settings_sms', label: '短信', group: '系统设置' },
|
||||||
{ key: 'system_settings_wechat', label: '微信', group: '系统设置' },
|
{ key: 'system_settings_wechat', label: '微信', group: '系统设置' },
|
||||||
{ key: 'system_settings_wechat_mini', 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_oss', label: '对象存储 OSS', group: '系统设置' },
|
||||||
{ key: 'system_settings_app', label: '应用链接', group: '系统设置' },
|
{ key: 'system_settings_app', label: '应用链接', group: '系统设置' },
|
||||||
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
|
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
|
||||||
@@ -61,7 +59,6 @@ export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = {
|
|||||||
sms: 'system_settings_sms',
|
sms: 'system_settings_sms',
|
||||||
wechat: 'system_settings_wechat',
|
wechat: 'system_settings_wechat',
|
||||||
wechat_mini: 'system_settings_wechat_mini',
|
wechat_mini: 'system_settings_wechat_mini',
|
||||||
wechat_mini_share: 'system_settings_wechat_mini_share',
|
|
||||||
oss: 'system_settings_oss',
|
oss: 'system_settings_oss',
|
||||||
app: 'system_settings_app',
|
app: 'system_settings_app',
|
||||||
deploy: 'system_settings_deploy',
|
deploy: 'system_settings_deploy',
|
||||||
@@ -119,11 +116,9 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
|||||||
'llm_configs',
|
'llm_configs',
|
||||||
'knowledge_bases',
|
'knowledge_bases',
|
||||||
'dev_plan',
|
'dev_plan',
|
||||||
'test_whitelist',
|
|
||||||
'resources',
|
'resources',
|
||||||
'logs',
|
'logs',
|
||||||
'system_settings_wechat_mini',
|
'system_settings_wechat_mini',
|
||||||
'system_settings_wechat_mini_share',
|
|
||||||
],
|
],
|
||||||
FINANCE: [
|
FINANCE: [
|
||||||
'dashboard',
|
'dashboard',
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ export interface StorePackageItemDto {
|
|||||||
dishes: string;
|
dishes: string;
|
||||||
usableTime?: string | null;
|
usableTime?: string | null;
|
||||||
otherNotes?: string | null;
|
otherNotes?: string | null;
|
||||||
/** 首图(兼容旧字段;多图时等于 imageUrls[0]) */
|
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
/** 套餐图片列表,最多 STORE_PACKAGE_IMAGE_MAX_COUNT 张 */
|
|
||||||
imageUrls?: string[] | null;
|
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,49 +25,6 @@ export const STORE_PACKAGE_CHANGE_STATUS_LABELS: Record<StorePackageChangeStatus
|
|||||||
|
|
||||||
export const STORE_PACKAGE_MAX_COUNT = 10;
|
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 {
|
export interface StorePackagesResponse {
|
||||||
live: StorePackageViewDto[];
|
live: StorePackageViewDto[];
|
||||||
pendingRequest?: {
|
pendingRequest?: {
|
||||||
@@ -105,10 +59,6 @@ export interface StorePackageAuditAction {
|
|||||||
rejectReason?: string;
|
rejectReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StorePackageAuditSummaryDto {
|
|
||||||
pendingCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreatePackageDisputeRequest {
|
export interface CreatePackageDisputeRequest {
|
||||||
storeId: string;
|
storeId: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { MiniShareRuntime } from './config';
|
|
||||||
|
|
||||||
/** 微信 JSSDK 初始化参数(后端签名下发) */
|
/** 微信 JSSDK 初始化参数(后端签名下发) */
|
||||||
export interface WechatJssdkConfig {
|
export interface WechatJssdkConfig {
|
||||||
appId: string;
|
appId: string;
|
||||||
@@ -52,24 +50,6 @@ export type ClientRuntimeConfig = {
|
|||||||
};
|
};
|
||||||
/** 小程序最低兼容版本(semver,如 3.4.13);客户端低于此值时提示更新 */
|
/** 小程序最低兼容版本(semver,如 3.4.13);客户端低于此值时提示更新 */
|
||||||
minClientVersion?: string | null;
|
minClientVersion?: string | null;
|
||||||
/** C 端 H5 落地页(推广码等) */
|
|
||||||
userH5Url?: string;
|
|
||||||
/** 方形品牌 Logo */
|
|
||||||
brandLogoUrl?: string;
|
|
||||||
/** 长方形品牌 Logo(登录) */
|
|
||||||
brandLogoWideUrl?: string;
|
|
||||||
/** 图标 Logo(默认头像) */
|
|
||||||
brandLogoMarkUrl?: string;
|
|
||||||
/** 「我的」资质公示长图 */
|
|
||||||
qualificationDisclosureUrl?: string;
|
|
||||||
/** 总部客服电话 */
|
|
||||||
customerServicePhone?: string;
|
|
||||||
/** 合伙人入驻:企微客服二维码图片 URL */
|
|
||||||
partnerOnboardCsQrUrl?: string | null;
|
|
||||||
/** 合伙人入驻:企微客服提示文案 */
|
|
||||||
partnerOnboardCsHint?: string | null;
|
|
||||||
/** 小程序各场景分享文案/图 */
|
|
||||||
share?: MiniShareRuntime;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 是否展示微信授权入口 */
|
/** 是否展示微信授权入口 */
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# @dukang/weixin-sdk · 踩坑
|
|
||||||
|
|
||||||
## iOS 微信 H5:JSSDK 入场 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 上返回入场 URL;OAuth 的 `code/state` **必须保留**参与签名;后端 `jssdk-config` 只去 `#`,不要删 query。
|
|
||||||
3. **先 `captureIosJssdkEntryUrl()`,再 `stripOAuthParamsFromLocation()`**。
|
|
||||||
4. 失败恢复:引导用户刷新页面或重新走 OAuth,而不是无限「再点一次」。
|
|
||||||
|
|
||||||
### 相关 API
|
|
||||||
|
|
||||||
- `captureIosJssdkEntryUrl` / `getJssdkSignUrl`
|
|
||||||
- `shouldHardNavigateForJssdk` / `hardNavigateInWechat`
|
|
||||||
- `stripOAuthParamsFromLocation`
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user