Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b40eb83f27 | |||
| 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
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.5.1",
|
"@ant-design/icons": "^5.5.1",
|
||||||
"@dukang/shared-types": "workspace:*",
|
"@dukang/shared-types": "workspace:*",
|
||||||
"@dukang/shared-ui": "workspace:*",
|
|
||||||
"antd": "^5.22.0",
|
"antd": "^5.22.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.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>
|
||||||
|
|||||||
@@ -2,22 +2,13 @@ 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;
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
|
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
|
||||||
@@ -32,16 +23,7 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
|
|||||||
.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);
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
price: String(p.price),
|
|
||||||
imageUrl: imageUrls[0] ?? '',
|
|
||||||
imageUrls,
|
|
||||||
sortOrder: i,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
: [emptyRow()],
|
: [emptyRow()],
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -92,20 +74,16 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
|
|||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
const filled = items
|
const filled = items
|
||||||
.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);
|
||||||
.filter((item) => item.name || item.dishes || item.price || item.imageUrls.length > 0);
|
|
||||||
|
|
||||||
for (let i = 0; i < filled.length; i++) {
|
for (let i = 0; i < filled.length; i++) {
|
||||||
const item = filled[i];
|
const item = filled[i];
|
||||||
@@ -122,35 +100,17 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
|
|||||||
message.warning(`第 ${i + 1} 条套餐价格须为非负数字`);
|
message.warning(`第 ${i + 1} 条套餐价格须为非负数字`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
|
|
||||||
message.warning(`第 ${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) })),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
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,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
: [emptyRow()],
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '保存失败');
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -255,14 +215,11 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
|
|||||||
</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>
|
||||||
|
|
||||||
@@ -285,7 +242,7 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
|
|||||||
) : 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()}>
|
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||||
|
|||||||
@@ -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,184 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { Button, Image, Space, Typography, Upload, message } from 'antd';
|
|
||||||
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
|
|
||||||
import type { UploadProps } from 'antd';
|
|
||||||
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
value?: string[];
|
|
||||||
onChange?: (urls: string[]) => void;
|
|
||||||
bizType: string;
|
|
||||||
mediaType?: OssMediaType;
|
|
||||||
/** 最多张数;不传则不限制 */
|
|
||||||
maxCount?: number;
|
|
||||||
tip?: string;
|
|
||||||
accept?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeUrls(value?: string[]) {
|
|
||||||
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 多图批量上传(一次可选多张),用于套餐图 / 环境照 / 商品详情图等。
|
|
||||||
* Form.Item 直接绑定 string[]。
|
|
||||||
*/
|
|
||||||
export default function MultiImageUpload({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
bizType,
|
|
||||||
mediaType = 'IMAGE',
|
|
||||||
maxCount,
|
|
||||||
tip,
|
|
||||||
accept = 'image/*',
|
|
||||||
}: Props) {
|
|
||||||
const urls = normalizeUrls(value);
|
|
||||||
const urlsRef = useRef(urls);
|
|
||||||
const onChangeRef = useRef(onChange);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const batchBuf = useRef<File[]>([]);
|
|
||||||
const batchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const uploadChain = useRef(Promise.resolve());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
urlsRef.current = urls;
|
|
||||||
}, [urls]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onChangeRef.current = onChange;
|
|
||||||
}, [onChange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (batchTimer.current) clearTimeout(batchTimer.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const remaining = maxCount != null ? Math.max(0, maxCount - urls.length) : Number.POSITIVE_INFINITY;
|
|
||||||
const canAdd = remaining > 0;
|
|
||||||
|
|
||||||
async function uploadBatch(files: File[]) {
|
|
||||||
const current = urlsRef.current;
|
|
||||||
const room = maxCount != null ? Math.max(0, maxCount - current.length) : files.length;
|
|
||||||
const picked = files.slice(0, room);
|
|
||||||
if (!picked.length) {
|
|
||||||
message.warning(maxCount != null ? `最多 ${maxCount} 张` : '无法上传');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (files.length > picked.length) {
|
|
||||||
message.warning(`已达上限,仅上传前 ${picked.length} 张`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploading(true);
|
|
||||||
const appended: string[] = [];
|
|
||||||
let fail = 0;
|
|
||||||
try {
|
|
||||||
for (const file of picked) {
|
|
||||||
try {
|
|
||||||
const result = await uploadFileToOss(file, { bizType, mediaType });
|
|
||||||
appended.push(result.url);
|
|
||||||
} catch {
|
|
||||||
fail += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (appended.length) {
|
|
||||||
// 始终基于最新列表追加,避免并行上传互相覆盖
|
|
||||||
const next = [...urlsRef.current, ...appended];
|
|
||||||
urlsRef.current = next;
|
|
||||||
onChangeRef.current?.(next);
|
|
||||||
message.success(`成功上传 ${appended.length} 张${fail ? `,失败 ${fail} 张` : ''}`);
|
|
||||||
} else if (fail) {
|
|
||||||
message.error('上传失败');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function enqueueUploadBatch(files: File[]) {
|
|
||||||
uploadChain.current = uploadChain.current
|
|
||||||
.then(() => uploadBatch(files))
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushBatch() {
|
|
||||||
if (batchTimer.current) {
|
|
||||||
clearTimeout(batchTimer.current);
|
|
||||||
batchTimer.current = null;
|
|
||||||
}
|
|
||||||
if (!batchBuf.current.length) return;
|
|
||||||
const files = [...batchBuf.current];
|
|
||||||
batchBuf.current = [];
|
|
||||||
enqueueUploadBatch(files);
|
|
||||||
}
|
|
||||||
|
|
||||||
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
|
||||||
batchBuf.current.push(file as File);
|
|
||||||
// 多选时 beforeUpload 可能逐文件触发;短防抖合并成一次批量
|
|
||||||
if (batchTimer.current) clearTimeout(batchTimer.current);
|
|
||||||
batchTimer.current = setTimeout(() => {
|
|
||||||
flushBatch();
|
|
||||||
}, 80);
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
function removeAt(index: number) {
|
|
||||||
const next = urlsRef.current.filter((_, i) => i !== index);
|
|
||||||
urlsRef.current = next;
|
|
||||||
onChangeRef.current?.(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
{tip ??
|
|
||||||
(maxCount != null
|
|
||||||
? `最多 ${maxCount} 张,支持一次选择多张批量上传`
|
|
||||||
: '支持一次选择多张批量上传')}
|
|
||||||
{maxCount != null ? `(已选 ${urls.length}/${maxCount})` : urls.length ? `(已选 ${urls.length})` : ''}
|
|
||||||
</Typography.Text>
|
|
||||||
|
|
||||||
{urls.length > 0 ? (
|
|
||||||
<Image.PreviewGroup>
|
|
||||||
<Space wrap size={12}>
|
|
||||||
{urls.map((url, index) => (
|
|
||||||
<div key={`${url}-${index}`} style={{ position: 'relative', width: 96 }}>
|
|
||||||
<Image
|
|
||||||
src={url}
|
|
||||||
width={96}
|
|
||||||
height={96}
|
|
||||||
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
danger
|
|
||||||
size="small"
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
onClick={() => removeAt(index)}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
background: 'rgba(255,255,255,0.85)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
</Image.PreviewGroup>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Upload
|
|
||||||
accept={accept}
|
|
||||||
multiple
|
|
||||||
showUploadList={false}
|
|
||||||
beforeUpload={beforeUpload}
|
|
||||||
disabled={uploading || !canAdd}
|
|
||||||
>
|
|
||||||
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canAdd}>
|
|
||||||
{canAdd ? '批量上传图片' : '已达上限'}
|
|
||||||
</Button>
|
|
||||||
</Upload>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,8 @@ type OssUploadProps = {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_MAX_MB = 10;
|
||||||
|
|
||||||
export default function OssUpload({
|
export default function OssUpload({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -22,6 +24,7 @@ export default function OssUpload({
|
|||||||
bizType,
|
bizType,
|
||||||
mediaType = 'IMAGE',
|
mediaType = 'IMAGE',
|
||||||
accept,
|
accept,
|
||||||
|
maxSizeMb = DEFAULT_MAX_MB,
|
||||||
placeholder = '上传后自动填入,或手动粘贴 URL',
|
placeholder = '上传后自动填入,或手动粘贴 URL',
|
||||||
}: OssUploadProps) {
|
}: OssUploadProps) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
@@ -31,6 +34,12 @@ export default function OssUpload({
|
|||||||
|
|
||||||
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
|
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
|
||||||
const raw = file as File;
|
const raw = file as File;
|
||||||
|
if (raw.size > maxSizeMb * 1024 * 1024) {
|
||||||
|
const err = new Error(`文件不能超过 ${maxSizeMb}MB`);
|
||||||
|
message.error(err.message);
|
||||||
|
onError?.(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const result = await uploadFileToOss(raw, { bizType, mediaType });
|
const result = await uploadFileToOss(raw, { bizType, mediaType });
|
||||||
|
|||||||
@@ -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,84 +0,0 @@
|
|||||||
import { Descriptions } from 'antd';
|
|
||||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
|
||||||
import { fmtTime } from '../lib/constants';
|
|
||||||
|
|
||||||
function maskPhone(phone: string | null | undefined) {
|
|
||||||
if (!phone || phone.length < 7) return phone ?? '—';
|
|
||||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
detail: Record<string, unknown>;
|
|
||||||
/** 列表页已脱敏展示时可设为 false,详情抽屉展示完整手机号 */
|
|
||||||
maskUserPhone?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) {
|
|
||||||
const user = detail.user as
|
|
||||||
| { userNo?: string; nickname?: string | null; phone?: string | null }
|
|
||||||
| undefined;
|
|
||||||
const store = detail.store as
|
|
||||||
| {
|
|
||||||
name?: string;
|
|
||||||
cityName?: string;
|
|
||||||
address?: string;
|
|
||||||
partnerAccount?: { companyName?: string };
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
const coupon = detail.coupon as { couponNo?: string; order?: { orderNo?: string } } | undefined;
|
|
||||||
const channel = detail.channel === 'PHONE' ? 'PHONE' : 'SCAN';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Descriptions column={1} bordered size="small">
|
|
||||||
<Descriptions.Item label="核销号">{String(detail.redeemNo ?? '—')}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="方式">{REDEEM_CHANNEL_LABELS[channel as RedeemChannel]}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="核销额">¥{Number(detail.amount ?? 0).toFixed(2)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="用户昵称">{user?.nickname?.trim() || '—'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="用户手机">
|
|
||||||
{maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="门店">
|
|
||||||
{store?.name ?? '—'}
|
|
||||||
{store?.cityName ? `(${store.cityName})` : ''}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="门店地址">{store?.address ?? '—'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="合伙人">{store?.partnerAccount?.companyName ?? '—'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="权益券号">{coupon?.couponNo ?? '—'}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="关联订单">{coupon?.order?.orderNo ?? '—'}</Descriptions.Item>
|
|
||||||
{Array.isArray(detail.allocations) && (detail.allocations as unknown[]).length > 0 ? (
|
|
||||||
<Descriptions.Item label="券分摊">
|
|
||||||
{(
|
|
||||||
detail.allocations as Array<{
|
|
||||||
couponNo?: string;
|
|
||||||
orderNo?: string | null;
|
|
||||||
amount?: number;
|
|
||||||
}>
|
|
||||||
)
|
|
||||||
.map((a, idx) => {
|
|
||||||
const role = idx === 0 ? '主' : '次';
|
|
||||||
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
|
|
||||||
a.orderNo ? `(订单 ${a.orderNo})` : ''
|
|
||||||
}`;
|
|
||||||
})
|
|
||||||
.join(';')}
|
|
||||||
</Descriptions.Item>
|
|
||||||
) : null}
|
|
||||||
{detail.payout ? (
|
|
||||||
<Descriptions.Item label="门店结算单">
|
|
||||||
¥{Number((detail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
|
|
||||||
{' / '}
|
|
||||||
{String((detail.payout as { status?: string }).status ?? '—')}
|
|
||||||
</Descriptions.Item>
|
|
||||||
) : null}
|
|
||||||
{detail.rating ? (
|
|
||||||
<Descriptions.Item label="评价">
|
|
||||||
服务 {(detail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '}
|
|
||||||
{(detail.rating as { envScore?: number }).envScore ?? '—'} 分
|
|
||||||
</Descriptions.Item>
|
|
||||||
) : null}
|
|
||||||
</Descriptions>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -146,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账户' },
|
||||||
];
|
];
|
||||||
@@ -216,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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -167,7 +167,6 @@ export type AdminUserRow = {
|
|||||||
sourceLabel: string | null;
|
sourceLabel: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
isTest?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminOrderItem = {
|
export type AdminOrderItem = {
|
||||||
@@ -200,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;
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export type StoreCreateForm = {
|
|||||||
bankAccountNo: string;
|
bankAccountNo: string;
|
||||||
bankBranch: string;
|
bankBranch: string;
|
||||||
settlementRate?: number;
|
settlementRate?: number;
|
||||||
sortOrder?: number;
|
|
||||||
visibilityWhitelistEnabled?: boolean;
|
visibilityWhitelistEnabled?: boolean;
|
||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
|
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
|
||||||
import {
|
|
||||||
compressImageFileIfNeeded,
|
|
||||||
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
|
||||||
formatOssMaxSizeMb,
|
|
||||||
} from '@dukang/shared-ui/compressImage';
|
|
||||||
|
|
||||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||||
|
|
||||||
@@ -14,24 +9,14 @@ export type UploadFileResult = {
|
|||||||
mock: boolean;
|
mock: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
|
|
||||||
if (mediaType !== 'IMAGE') return file;
|
|
||||||
const compressed = await compressImageFileIfNeeded(file);
|
|
||||||
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
|
|
||||||
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
|
|
||||||
}
|
|
||||||
return compressed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
|
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
|
||||||
export async function uploadFileToOss(
|
export async function uploadFileToOss(
|
||||||
file: File,
|
file: File,
|
||||||
options: { bizType: string; mediaType?: OssMediaType },
|
options: { bizType: string; mediaType?: OssMediaType },
|
||||||
): Promise<UploadFileResult> {
|
): Promise<UploadFileResult> {
|
||||||
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||||
const prepared = await prepareUploadFile(file, mediaType);
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', prepared);
|
formData.append('file', file);
|
||||||
formData.append('bizType', options.bizType);
|
formData.append('bizType', options.bizType);
|
||||||
formData.append('mediaType', mediaType);
|
formData.append('mediaType', mediaType);
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
|
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -243,8 +242,7 @@ export default function BenefitCouponsPage() {
|
|||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title="权益券详情"
|
title="权益券详情"
|
||||||
width={980}
|
width={720}
|
||||||
styles={{ body: { paddingBottom: 24 } }}
|
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
@@ -312,15 +310,14 @@ export default function BenefitCouponsPage() {
|
|||||||
size="small"
|
size="small"
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
tableLayout="fixed"
|
|
||||||
locale={{ emptyText: '暂无关联核销单' }}
|
locale={{ emptyText: '暂无关联核销单' }}
|
||||||
dataSource={detail.redeemRecords ?? []}
|
dataSource={detail.redeemRecords ?? []}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '核销号', dataIndex: 'redeemNo', ellipsis: true },
|
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true },
|
||||||
{
|
{
|
||||||
title: '角色',
|
title: '角色',
|
||||||
dataIndex: 'role',
|
dataIndex: 'role',
|
||||||
width: 72,
|
width: 70,
|
||||||
render: (v: string | undefined) =>
|
render: (v: string | undefined) =>
|
||||||
v === 'SECONDARY' ? <Tag color="orange">次券</Tag> : <Tag color="blue">主券</Tag>,
|
v === 'SECONDARY' ? <Tag color="orange">次券</Tag> : <Tag color="blue">主券</Tag>,
|
||||||
},
|
},
|
||||||
@@ -334,31 +331,31 @@ export default function BenefitCouponsPage() {
|
|||||||
{
|
{
|
||||||
title: '本券分摊',
|
title: '本券分摊',
|
||||||
dataIndex: 'couponAmount',
|
dataIndex: 'couponAmount',
|
||||||
width: 96,
|
width: 95,
|
||||||
render: (v: number | undefined, row: CouponRedeemRecord) =>
|
render: (v: number | undefined, row: CouponRedeemRecord) =>
|
||||||
`¥${Number(v ?? row.amount).toFixed(2)}`,
|
`¥${Number(v ?? row.amount).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '核销总额',
|
title: '核销总额',
|
||||||
dataIndex: 'amount',
|
dataIndex: 'amount',
|
||||||
width: 96,
|
width: 90,
|
||||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '结算额',
|
title: '结算额',
|
||||||
dataIndex: 'settleAmount',
|
dataIndex: 'settleAmount',
|
||||||
width: 96,
|
width: 90,
|
||||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '时间',
|
title: '时间',
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
width: 148,
|
width: 150,
|
||||||
render: (v: string) => fmtTime(v),
|
render: (v: string) => fmtTime(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 64,
|
width: 70,
|
||||||
render: (_: unknown, row: CouponRedeemRecord) => (
|
render: (_: unknown, row: CouponRedeemRecord) => (
|
||||||
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
|
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
|
||||||
详情
|
详情
|
||||||
@@ -377,7 +374,7 @@ export default function BenefitCouponsPage() {
|
|||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title="核销单详情"
|
title="核销单详情"
|
||||||
width={640}
|
width={520}
|
||||||
open={redeemDrawerOpen}
|
open={redeemDrawerOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setRedeemDrawerOpen(false);
|
setRedeemDrawerOpen(false);
|
||||||
@@ -388,7 +385,79 @@ export default function BenefitCouponsPage() {
|
|||||||
{redeemDetailLoading ? (
|
{redeemDetailLoading ? (
|
||||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||||
) : redeemDetail ? (
|
) : redeemDetail ? (
|
||||||
<RedeemRecordDetailDescriptions detail={redeemDetail} />
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="核销额">
|
||||||
|
¥{Number(redeemDetail.amount ?? 0).toFixed(2)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="结算额">
|
||||||
|
¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间">{fmtTime(String(redeemDetail.createdAt ?? ''))}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户">
|
||||||
|
{String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
|
||||||
|
{(redeemDetail.user as { phone?: string | null } | undefined)?.phone
|
||||||
|
? ` / ${(redeemDetail.user as { phone?: string | null }).phone}`
|
||||||
|
: ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="门店">
|
||||||
|
{String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')}
|
||||||
|
{(redeemDetail.store as { cityName?: string } | undefined)?.cityName
|
||||||
|
? `(${(redeemDetail.store as { cityName?: string }).cityName})`
|
||||||
|
: ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="门店地址">
|
||||||
|
{String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="合伙人">
|
||||||
|
{String(
|
||||||
|
(redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined)
|
||||||
|
?.partnerAccount?.companyName ?? '—',
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="权益券号">
|
||||||
|
{String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联订单">
|
||||||
|
{String(
|
||||||
|
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
|
||||||
|
'—',
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
{Array.isArray(redeemDetail.allocations) &&
|
||||||
|
(redeemDetail.allocations as unknown[]).length > 0 ? (
|
||||||
|
<Descriptions.Item label="券分摊">
|
||||||
|
{(
|
||||||
|
redeemDetail.allocations as Array<{
|
||||||
|
couponNo?: string;
|
||||||
|
orderNo?: string | null;
|
||||||
|
amount?: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
}>
|
||||||
|
)
|
||||||
|
.map((a, idx) => {
|
||||||
|
const role = idx === 0 ? '主' : '次';
|
||||||
|
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
|
||||||
|
a.orderNo ? `(订单 ${a.orderNo})` : ''
|
||||||
|
}`;
|
||||||
|
})
|
||||||
|
.join(';')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
{redeemDetail.payout ? (
|
||||||
|
<Descriptions.Item label="门店结算单">
|
||||||
|
¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
|
||||||
|
{' / '}
|
||||||
|
{String((redeemDetail.payout as { status?: string }).status ?? '—')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
{redeemDetail.rating ? (
|
||||||
|
<Descriptions.Item label="评价">
|
||||||
|
服务 {(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '}
|
||||||
|
{(redeemDetail.rating as { envScore?: number }).envScore ?? '—'} 分
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
</Descriptions>
|
||||||
) : null}
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,10 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import {
|
import {
|
||||||
DEV_PLAN_TASK_STATUS_LABELS,
|
DEV_PLAN_TASK_STATUS_LABELS,
|
||||||
DEV_PLAN_TASK_TYPE_LABELS,
|
DEV_PLAN_TASK_TYPE_LABELS,
|
||||||
SUPPORT_TICKET_STATUS_LABELS,
|
|
||||||
type DevPlanTaskDto,
|
type DevPlanTaskDto,
|
||||||
type DevPlanTaskStatusDto,
|
type DevPlanTaskStatusDto,
|
||||||
type DevPlanTaskTypeDto,
|
type DevPlanTaskTypeDto,
|
||||||
type DevPlanVersionDto,
|
type DevPlanVersionDto,
|
||||||
type SupportTicketDto,
|
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
@@ -52,7 +50,6 @@ export default function DevPlanTasksPage() {
|
|||||||
const [batchEditOpen, setBatchEditOpen] = useState(false);
|
const [batchEditOpen, setBatchEditOpen] = useState(false);
|
||||||
const [batchSaving, setBatchSaving] = useState(false);
|
const [batchSaving, setBatchSaving] = useState(false);
|
||||||
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
|
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
|
||||||
const [supportTickets, setSupportTickets] = useState<SupportTicketDto[]>([]);
|
|
||||||
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
|
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [dispatching, setDispatching] = useState(false);
|
const [dispatching, setDispatching] = useState(false);
|
||||||
@@ -67,13 +64,6 @@ export default function DevPlanTasksPage() {
|
|||||||
.catch(() => setVersions([]));
|
.catch(() => setVersions([]));
|
||||||
}, [batchEditOpen]);
|
}, [batchEditOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!modalOpen || editing) return;
|
|
||||||
request<{ items: SupportTicketDto[] }>('/admin/support-tickets?pageSize=100')
|
|
||||||
.then((res) => setSupportTickets(res.items ?? []))
|
|
||||||
.catch(() => setSupportTickets([]));
|
|
||||||
}, [modalOpen, editing]);
|
|
||||||
|
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<DevPlanTaskDto>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<DevPlanTaskDto>(
|
||||||
'/admin/dev-plan/tasks',
|
'/admin/dev-plan/tasks',
|
||||||
() => {
|
() => {
|
||||||
@@ -88,7 +78,7 @@ export default function DevPlanTasksPage() {
|
|||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined });
|
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO' });
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,11 +101,7 @@ export default function DevPlanTasksPage() {
|
|||||||
} else {
|
} else {
|
||||||
await request('/admin/dev-plan/tasks', {
|
await request('/admin/dev-plan/tasks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ content: values.content, type: values.type }),
|
||||||
content: values.content,
|
|
||||||
type: values.type,
|
|
||||||
supportTicketId: values.supportTicketId || undefined,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
message.success('已创建');
|
message.success('已创建');
|
||||||
}
|
}
|
||||||
@@ -319,20 +305,7 @@ export default function DevPlanTasksPage() {
|
|||||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||||
<Select options={STATUS_OPTIONS} />
|
<Select options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : (
|
) : null}
|
||||||
<Form.Item name="supportTicketId" label="绑定技术支持工单">
|
|
||||||
<Select
|
|
||||||
allowClear
|
|
||||||
showSearch
|
|
||||||
optionFilterProp="label"
|
|
||||||
placeholder="可选:关联工单"
|
|
||||||
options={supportTickets.map((t) => ({
|
|
||||||
value: t.id,
|
|
||||||
label: `${t.ticketNo} · ${SUPPORT_TICKET_STATUS_LABELS[t.status]} · ${t.title}`,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
import { Button, Descriptions, 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 { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
@@ -14,62 +13,30 @@ type Row = {
|
|||||||
settleAmount: number;
|
settleAmount: number;
|
||||||
channel?: RedeemChannel;
|
channel?: RedeemChannel;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isTest?: boolean;
|
user?: { userNo: string; phone: 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 };
|
||||||
};
|
};
|
||||||
|
|
||||||
function maskPhone(phone: string | null | undefined) {
|
|
||||||
if (!phone || phone.length < 7) return phone ?? '—';
|
|
||||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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],
|
||||||
);
|
);
|
||||||
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 [detailLoading, setDetailLoading] = useState(false);
|
|
||||||
|
|
||||||
async function openDetail(id: string) {
|
|
||||||
setDetailLoading(true);
|
|
||||||
setDrawerOpen(true);
|
|
||||||
try {
|
|
||||||
setDetail(await request(`/admin/redeem-records/${id}`));
|
|
||||||
} finally {
|
|
||||||
setDetailLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
||||||
@@ -83,19 +50,7 @@ export default function RedeemRecordsPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 },
|
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
|
||||||
{
|
|
||||||
title: '用户昵称',
|
|
||||||
dataIndex: ['user', 'nickname'],
|
|
||||||
width: 100,
|
|
||||||
render: (v: string | null | undefined) => v || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '用户手机',
|
|
||||||
dataIndex: ['user', 'phone'],
|
|
||||||
width: 120,
|
|
||||||
render: (v: string | null | undefined) => maskPhone(v),
|
|
||||||
},
|
|
||||||
{ title: '门店', dataIndex: ['store', 'name'] },
|
{ title: '门店', dataIndex: ['store', 'name'] },
|
||||||
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||||
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
|
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
|
||||||
@@ -105,7 +60,14 @@ export default function RedeemRecordsPage() {
|
|||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
setDetail(await request(`/admin/redeem-records/${row.id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
@@ -140,9 +102,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">
|
||||||
查询
|
查询
|
||||||
@@ -154,7 +113,7 @@ export default function RedeemRecordsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1300 }}
|
scroll={{ x: 1100 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -166,21 +125,22 @@ export default function RedeemRecordsPage() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Drawer
|
<Drawer title="核销详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||||
title="核销详情"
|
{detail && (
|
||||||
width={640}
|
<Descriptions column={1} bordered size="small">
|
||||||
open={drawerOpen}
|
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item>
|
||||||
onClose={() => {
|
<Descriptions.Item label="方式">
|
||||||
setDrawerOpen(false);
|
{
|
||||||
setDetail(null);
|
REDEEM_CHANNEL_LABELS[
|
||||||
}}
|
(detail.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel
|
||||||
destroyOnClose
|
]
|
||||||
>
|
}
|
||||||
{detailLoading ? (
|
</Descriptions.Item>
|
||||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item>
|
||||||
) : detail ? (
|
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
|
||||||
<RedeemRecordDetailDescriptions detail={detail} />
|
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||||
) : null}
|
</Descriptions>
|
||||||
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,8 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
@@ -23,10 +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 { 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,
|
||||||
@@ -40,7 +40,6 @@ 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 from '../components/AdminStorePackagesSection';
|
import AdminStorePackagesSection from '../components/AdminStorePackagesSection';
|
||||||
|
|
||||||
@@ -82,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) =>
|
||||||
@@ -107,38 +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="contractUrl" label="签约合同" style={{ marginTop: 16 }}>
|
) : (
|
||||||
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
|
<div
|
||||||
</Form.Item>
|
style={{
|
||||||
|
width: 96,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px dashed #d9d9d9',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#cf1322',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilePdfOutlined style={{ fontSize: 28 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Space direction="vertical" size={4} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography.Text strong>
|
||||||
|
{RESOURCE_BIZ_TYPE_LABELS.CONTRACT || '合同'}
|
||||||
|
{contracts.length > 1 ? ` ${index + 1}` : ''}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" ellipsis style={{ maxWidth: '100%' }}>
|
||||||
|
{item.url}
|
||||||
|
</Typography.Text>
|
||||||
|
<Space wrap>
|
||||||
|
{imageLike ? (
|
||||||
|
<Typography.Text type="secondary">点击缩略图放大查看</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
{pdf ? (
|
||||||
|
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setPdfUrl(item.url)}>
|
||||||
|
页内预览 PDF
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<LinkOutlined />}
|
||||||
|
style={{ padding: 0 }}
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
新窗口打开
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="合同预览"
|
||||||
|
open={!!pdfUrl}
|
||||||
|
onCancel={() => setPdfUrl(null)}
|
||||||
|
width={900}
|
||||||
|
footer={[
|
||||||
|
<Button key="open" href={pdfUrl || undefined} target="_blank" rel="noreferrer">
|
||||||
|
新窗口打开
|
||||||
|
</Button>,
|
||||||
|
<Button key="close" type="primary" onClick={() => setPdfUrl(null)}>
|
||||||
|
关闭
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{pdfUrl ? (
|
||||||
|
<iframe
|
||||||
|
title="合同 PDF 预览"
|
||||||
|
src={pdfUrl}
|
||||||
|
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
</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 (
|
||||||
@@ -147,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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -175,9 +375,8 @@ 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?: { companyName: string };
|
||||||
account?: {
|
account?: {
|
||||||
phone: string;
|
phone: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -192,18 +391,16 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PartnerOption = { id: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
type PartnerOption = { id: string; companyName: string; name?: string };
|
||||||
|
|
||||||
function partnerOptionLabel(p: PartnerOption): string {
|
function partnerOptionLabel(p: PartnerOption): string {
|
||||||
const company = (p.companyName || '').trim();
|
const company = (p.companyName || '').trim();
|
||||||
const person = (p.name || '').trim();
|
const person = (p.name || '').trim();
|
||||||
const phone = (p.phone || '').trim();
|
|
||||||
if (company && person) return `${company}-${person}`;
|
if (company && person) return `${company}-${person}`;
|
||||||
return company || person || phone || p.id;
|
return company || person || p.id;
|
||||||
}
|
}
|
||||||
type CityOption = {
|
type CityOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -226,8 +423,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;
|
||||||
@@ -236,13 +433,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],
|
||||||
@@ -379,15 +575,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;
|
|
||||||
})(),
|
|
||||||
contractUrl: (() => {
|
|
||||||
const { contracts } = collectMediaUrls(d);
|
|
||||||
return contracts[0]?.url || '';
|
|
||||||
})(),
|
|
||||||
province: d.province,
|
province: d.province,
|
||||||
city: d.cityName,
|
city: d.cityName,
|
||||||
district: d.district,
|
district: d.district,
|
||||||
@@ -409,8 +596,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);
|
||||||
}
|
}
|
||||||
@@ -428,11 +613,7 @@ export default function StoresPage() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
name: v.name,
|
name: v.name,
|
||||||
phone: v.phone,
|
phone: v.phone,
|
||||||
coverUrl: v.coverUrl ?? '',
|
coverUrl: v.coverUrl,
|
||||||
envPhotoUrls: Array.isArray(v.envPhotoUrls)
|
|
||||||
? v.envPhotoUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
|
|
||||||
: [],
|
|
||||||
contractUrl: v.contractUrl ?? '',
|
|
||||||
intro: v.intro,
|
intro: v.intro,
|
||||||
benefitUsageRule:
|
benefitUsageRule:
|
||||||
typeof v.benefitUsageRule === 'string' &&
|
typeof v.benefitUsageRule === 'string' &&
|
||||||
@@ -455,8 +636,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) }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -539,9 +721,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,
|
||||||
@@ -630,8 +811,10 @@ export default function StoresPage() {
|
|||||||
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('门店已创建');
|
||||||
@@ -655,12 +838,7 @@ 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: 160, render: (v, row) => (
|
{ title: '门店名', dataIndex: 'name', width: 140 },
|
||||||
<Space size={4}>
|
|
||||||
<span>{v}</span>
|
|
||||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
|
||||||
</Space>
|
|
||||||
) },
|
|
||||||
{
|
{
|
||||||
title: '分类',
|
title: '分类',
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -689,22 +867,15 @@ export default function StoresPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 120 },
|
||||||
title: '开城合伙人',
|
|
||||||
dataIndex: 'partner',
|
|
||||||
width: 140,
|
|
||||||
render: (partner: StoreRow['partner']) =>
|
|
||||||
partner ? partnerOptionLabel({ id: partner.id ?? '', ...partner }) : '—',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
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, render: (v) => v || '—' },
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
@@ -761,9 +932,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
|
||||||
@@ -984,13 +1152,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 }} />
|
||||||
@@ -1008,14 +1169,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>
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -1060,8 +1213,7 @@ export default function StoresPage() {
|
|||||||
{
|
{
|
||||||
key: 'media',
|
key: 'media',
|
||||||
label: '审核材料',
|
label: '审核材料',
|
||||||
forceRender: true,
|
children: <StoreAuditMediaSection detail={detail} />,
|
||||||
children: <StoreAuditMediaEditor />,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'packages',
|
key: 'packages',
|
||||||
@@ -1167,9 +1319,6 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="phone" label="门店手机号(登录账号)" rules={[{ required: true, message: '请填写门店手机号' }]}>
|
<Form.Item name="phone" label="门店手机号(登录账号)" rules={[{ required: true, message: '请填写门店手机号' }]}>
|
||||||
<Input placeholder="11位手机号" />
|
<Input placeholder="11位手机号" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="sortOrder" label="排序" extra="数值越小越靠前">
|
|
||||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="0" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
||||||
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -1254,18 +1403,24 @@ 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>
|
||||||
|
))}
|
||||||
|
<Button type="dashed" onClick={() => add('')} block>
|
||||||
|
添加环境照片
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
<Form.Item name="contractUrl" label="签约合同">
|
<Form.Item name="contractUrl" label="签约合同">
|
||||||
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
|
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
DEV_PLAN_TASK_TYPE_LABELS,
|
DEV_PLAN_TASK_TYPE_LABELS,
|
||||||
SUPPORT_TICKET_STATUS_LABELS,
|
SUPPORT_TICKET_STATUS_LABELS,
|
||||||
SUPPORT_TICKET_TYPE_LABELS,
|
SUPPORT_TICKET_TYPE_LABELS,
|
||||||
SUPPORT_TICKET_PRIORITY_LABELS,
|
|
||||||
mapSupportTicketTypeToDevPlanTask,
|
mapSupportTicketTypeToDevPlanTask,
|
||||||
type BatchReviewPreviewItem,
|
type BatchReviewPreviewItem,
|
||||||
type BatchReviewPreviewResponse,
|
type BatchReviewPreviewResponse,
|
||||||
@@ -38,7 +37,6 @@ import {
|
|||||||
type DevPlanVersionDto,
|
type DevPlanVersionDto,
|
||||||
type SupportTicketDto,
|
type SupportTicketDto,
|
||||||
type SupportTicketLinkedTaskDto,
|
type SupportTicketLinkedTaskDto,
|
||||||
type SupportTicketPriorityDto,
|
|
||||||
type SupportTicketStatusDto,
|
type SupportTicketStatusDto,
|
||||||
type SupportTicketTypeDto,
|
type SupportTicketTypeDto,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
@@ -56,7 +54,6 @@ const STATUS_COLOR: Record<SupportTicketStatusDto, string> = {
|
|||||||
DEVELOPING: 'blue',
|
DEVELOPING: 'blue',
|
||||||
TESTING: 'purple',
|
TESTING: 'purple',
|
||||||
PASSED: 'green',
|
PASSED: 'green',
|
||||||
PUBLISHED: 'cyan',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map(
|
const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map(
|
||||||
@@ -67,17 +64,6 @@ const STATUS_OPTIONS = (Object.keys(SUPPORT_TICKET_STATUS_LABELS) as SupportTick
|
|||||||
(value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }),
|
(value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const PRIORITY_COLOR: Record<SupportTicketPriorityDto, string> = {
|
|
||||||
LOW: 'default',
|
|
||||||
NORMAL: 'blue',
|
|
||||||
HIGH: 'orange',
|
|
||||||
URGENT: 'red',
|
|
||||||
};
|
|
||||||
|
|
||||||
const PRIORITY_OPTIONS = (Object.keys(SUPPORT_TICKET_PRIORITY_LABELS) as SupportTicketPriorityDto[]).map(
|
|
||||||
(value) => ({ value, label: SUPPORT_TICKET_PRIORITY_LABELS[value] }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map(
|
const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map(
|
||||||
(v) => ({ value: v, label: DEV_PLAN_TASK_TYPE_LABELS[v] }),
|
(v) => ({ value: v, label: DEV_PLAN_TASK_TYPE_LABELS[v] }),
|
||||||
);
|
);
|
||||||
@@ -94,7 +80,6 @@ export default function SupportTicketsPage() {
|
|||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.ticketType) qs.set('ticketType', filters.ticketType);
|
if (filters.ticketType) qs.set('ticketType', filters.ticketType);
|
||||||
if (filters.status) qs.set('status', filters.status);
|
if (filters.status) qs.set('status', filters.status);
|
||||||
if (filters.priority) qs.set('priority', filters.priority);
|
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -169,7 +154,6 @@ export default function SupportTicketsPage() {
|
|||||||
title: values.title.trim(),
|
title: values.title.trim(),
|
||||||
content: values.content?.trim() || undefined,
|
content: values.content?.trim() || undefined,
|
||||||
remark: values.remark?.trim() || undefined,
|
remark: values.remark?.trim() || undefined,
|
||||||
priority: values.priority,
|
|
||||||
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
|
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -233,7 +217,6 @@ export default function SupportTicketsPage() {
|
|||||||
title: detail.title,
|
title: detail.title,
|
||||||
content: detail.content || '',
|
content: detail.content || '',
|
||||||
remark: detail.remark || '',
|
remark: detail.remark || '',
|
||||||
priority: detail.priority ?? 'NORMAL',
|
|
||||||
attachmentUrls: detail.attachmentUrls?.length ? detail.attachmentUrls : [''],
|
attachmentUrls: detail.attachmentUrls?.length ? detail.attachmentUrls : [''],
|
||||||
});
|
});
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
@@ -250,7 +233,6 @@ export default function SupportTicketsPage() {
|
|||||||
title: values.title.trim(),
|
title: values.title.trim(),
|
||||||
content: values.content?.trim() || undefined,
|
content: values.content?.trim() || undefined,
|
||||||
remark: values.remark?.trim() || undefined,
|
remark: values.remark?.trim() || undefined,
|
||||||
priority: values.priority,
|
|
||||||
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
|
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -469,29 +451,12 @@ export default function SupportTicketsPage() {
|
|||||||
width: 90,
|
width: 90,
|
||||||
render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t,
|
render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '优先级',
|
|
||||||
dataIndex: 'priority',
|
|
||||||
width: 90,
|
|
||||||
render: (p: SupportTicketPriorityDto) => (
|
|
||||||
<Tag color={PRIORITY_COLOR[p] ?? 'default'}>
|
|
||||||
{SUPPORT_TICKET_PRIORITY_LABELS[p] ?? p ?? '普通'}
|
|
||||||
</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (s: SupportTicketStatusDto, row) => (
|
render: (s: SupportTicketStatusDto) => (
|
||||||
<Space size={4} direction="vertical" style={{ lineHeight: 1.2 }}>
|
|
||||||
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag>
|
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag>
|
||||||
{row.releasedVersionNo ? (
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
|
||||||
{row.releasedVersionNo}
|
|
||||||
</Typography.Text>
|
|
||||||
) : null}
|
|
||||||
</Space>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||||
@@ -597,9 +562,6 @@ export default function SupportTicketsPage() {
|
|||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
|
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="priority" label="优先级">
|
|
||||||
<Select allowClear style={{ width: 120 }} options={PRIORITY_OPTIONS} />
|
|
||||||
</Form.Item>
|
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
筛选
|
筛选
|
||||||
</Button>
|
</Button>
|
||||||
@@ -615,7 +577,11 @@ export default function SupportTicketsPage() {
|
|||||||
isSuperAdmin
|
isSuperAdmin
|
||||||
? {
|
? {
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
onChange: (keys, rows) => {
|
||||||
|
const pending = rows.filter((r) => r.status === 'PENDING_REVIEW').map((r) => String(r.id));
|
||||||
|
setSelectedRowKeys(pending.length === rows.length ? (keys as string[]) : pending);
|
||||||
|
},
|
||||||
|
getCheckboxProps: (row) => ({ disabled: row.status !== 'PENDING_REVIEW' }),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -641,9 +607,6 @@ export default function SupportTicketsPage() {
|
|||||||
{detail.ticketNo}
|
{detail.ticketNo}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Tag>{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}</Tag>
|
<Tag>{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}</Tag>
|
||||||
<Tag color={PRIORITY_COLOR[detail.priority ?? 'NORMAL']}>
|
|
||||||
{SUPPORT_TICKET_PRIORITY_LABELS[detail.priority ?? 'NORMAL']}
|
|
||||||
</Tag>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -674,11 +637,6 @@ export default function SupportTicketsPage() {
|
|||||||
{detail.status === 'PASSED' && (
|
{detail.status === 'PASSED' && (
|
||||||
<Typography.Text type="success">此工单已测试通过</Typography.Text>
|
<Typography.Text type="success">此工单已测试通过</Typography.Text>
|
||||||
)}
|
)}
|
||||||
{detail.status === 'PUBLISHED' && (
|
|
||||||
<Typography.Text type="success">
|
|
||||||
已发布{detail.releasedVersionNo ? ` · ${detail.releasedVersionNo}` : ''}
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
创建于 {fmtTime(detail.createdAt)}
|
创建于 {fmtTime(detail.createdAt)}
|
||||||
@@ -702,7 +660,6 @@ export default function SupportTicketsPage() {
|
|||||||
{ key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt },
|
{ key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt },
|
||||||
{ key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null },
|
{ key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null },
|
||||||
{ key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt },
|
{ key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt },
|
||||||
{ key: 'PUBLISHED' as SupportTicketStatusDto, label: '已发布', time: detail.publishedAt },
|
|
||||||
]
|
]
|
||||||
: [{ key: 'REJECTED' as SupportTicketStatusDto, label: '已驳回', time: detail.reviewedAt }]),
|
: [{ key: 'REJECTED' as SupportTicketStatusDto, label: '已驳回', time: detail.reviewedAt }]),
|
||||||
];
|
];
|
||||||
@@ -908,13 +865,10 @@ export default function SupportTicketsPage() {
|
|||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
<Modal title="创建技术支持工单" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交">
|
<Modal title="创建技术支持工单" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交">
|
||||||
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG', priority: 'NORMAL', attachmentUrls: [''] }}>
|
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG', attachmentUrls: [''] }}>
|
||||||
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
|
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
|
||||||
<Select options={TYPE_OPTIONS} />
|
<Select options={TYPE_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="priority" label="优先级" rules={[{ required: true }]}>
|
|
||||||
<Select options={PRIORITY_OPTIONS} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
||||||
<Input maxLength={128} showCount />
|
<Input maxLength={128} showCount />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -1018,9 +972,6 @@ export default function SupportTicketsPage() {
|
|||||||
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
|
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
|
||||||
<Select options={TYPE_OPTIONS} />
|
<Select options={TYPE_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="priority" label="优先级" rules={[{ required: true }]}>
|
|
||||||
<Select options={PRIORITY_OPTIONS} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
||||||
<Input maxLength={128} showCount />
|
<Input maxLength={128} showCount />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,18 +10,11 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
|
||||||
TICKET_STATUS_LABELS,
|
|
||||||
TICKET_TYPE_LABELS,
|
|
||||||
ticketStatusLabel,
|
|
||||||
type TicketStatusDto,
|
|
||||||
type TicketTypeDto,
|
|
||||||
} from '@dukang/shared-types';
|
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
@@ -38,21 +31,6 @@ type Row = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_COLOR: Partial<Record<TicketStatusDto, string>> = {
|
|
||||||
PENDING: 'orange',
|
|
||||||
OPEN: 'blue',
|
|
||||||
COLLABORATING: 'cyan',
|
|
||||||
RESOLVED: 'green',
|
|
||||||
REJECTED: 'red',
|
|
||||||
COMPLETED: 'green',
|
|
||||||
CLOSED: 'default',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_OPTIONS = (Object.keys(TICKET_STATUS_LABELS) as TicketStatusDto[]).map((value) => ({
|
|
||||||
value,
|
|
||||||
label: TICKET_STATUS_LABELS[value],
|
|
||||||
}));
|
|
||||||
|
|
||||||
export default function TicketsPage() {
|
export default function TicketsPage() {
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
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>(
|
||||||
@@ -123,14 +101,7 @@ export default function TicketsPage() {
|
|||||||
width: 110,
|
width: 110,
|
||||||
render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t,
|
render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t,
|
||||||
},
|
},
|
||||||
{
|
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
width: 100,
|
|
||||||
render: (s: string) => (
|
|
||||||
<Tag color={STATUS_COLOR[s as TicketStatusDto] ?? 'default'}>{ticketStatusLabel(s)}</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
@@ -194,7 +165,7 @@ export default function TicketsPage() {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select allowClear style={{ width: 140 }} placeholder="全部" options={STATUS_OPTIONS} />
|
<Input allowClear placeholder="PENDING" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
筛选
|
筛选
|
||||||
@@ -241,11 +212,7 @@ export default function TicketsPage() {
|
|||||||
<Descriptions.Item label="类型">
|
<Descriptions.Item label="类型">
|
||||||
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
|
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
||||||
<Tag color={STATUS_COLOR[detail.status as TicketStatusDto] ?? 'default'}>
|
|
||||||
{ticketStatusLabel(detail.status)}
|
|
||||||
</Tag>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="关联">
|
<Descriptions.Item label="关联">
|
||||||
{String(detail.refType)} #{String(detail.refId)}
|
{String(detail.refType)} #{String(detail.refId)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|||||||
@@ -112,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 {
|
||||||
@@ -238,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: '手机',
|
||||||
@@ -373,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>
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ export default function WineryBillsPage() {
|
|||||||
酒厂对账单
|
酒厂对账单
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
{canEditWineryBank ? (
|
{canEditWineryBank ? (
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
import { request } from '../../lib/api';
|
import { request } from '../../lib/api';
|
||||||
import { fmtTime } from '../../lib/constants';
|
import { fmtTime } from '../../lib/constants';
|
||||||
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||||
import PromoCodeMetricsPanel from './PromoCodeMetricsPanel';
|
|
||||||
|
|
||||||
const descLabelStyle: CSSProperties = {
|
const descLabelStyle: CSSProperties = {
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
@@ -213,14 +212,6 @@ export default function PromoCodeDetailPage() {
|
|||||||
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
|
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={6}>
|
|
||||||
<Card size="small">
|
|
||||||
<Statistic
|
|
||||||
title="归因用户数"
|
|
||||||
value={stats?.attributionCount ?? 0}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card size="small">
|
<Card size="small">
|
||||||
<Statistic
|
<Statistic
|
||||||
@@ -244,8 +235,6 @@ export default function PromoCodeDetailPage() {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<PromoCodeMetricsPanel promoId={detail.id} />
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="编辑推广码"
|
title="编辑推广码"
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
DatePicker,
|
|
||||||
Radio,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Table,
|
|
||||||
Typography,
|
|
||||||
message,
|
|
||||||
} from 'antd';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
import dayjs, { type Dayjs } from 'dayjs';
|
|
||||||
import ReactECharts from 'echarts-for-react';
|
|
||||||
import type { EChartsOption } from 'echarts';
|
|
||||||
import {
|
|
||||||
PROMO_METRIC_EVENT_LABELS,
|
|
||||||
type PromoMetricEventItem,
|
|
||||||
type PromoMetricEventType,
|
|
||||||
type PromoMetricTimelineDto,
|
|
||||||
} from '@dukang/shared-types';
|
|
||||||
import { request, type Paginated } from '../../lib/api';
|
|
||||||
import { fmtTime } from '../../lib/constants';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
promoId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SERIES = [
|
|
||||||
{ key: 'scan', name: '扫码进入', color: '#1677ff' },
|
|
||||||
{ key: 'attribution', name: '归因用户', color: '#52c41a' },
|
|
||||||
{ key: 'register', name: '扫码注册', color: '#faad14' },
|
|
||||||
{ key: 'order', name: '订单', color: '#eb2f96' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function buildTimelineQs(promoId: string, range: [Dayjs, Dayjs], granularity: 'day' | 'hour') {
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
qs.set('dateFrom', range[0].format('YYYY-MM-DD'));
|
|
||||||
qs.set('dateTo', range[1].format('YYYY-MM-DD'));
|
|
||||||
qs.set('granularity', granularity);
|
|
||||||
return `/admin/promo-codes/${promoId}/metrics/timeline?${qs.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEventsQs(
|
|
||||||
promoId: string,
|
|
||||||
page: number,
|
|
||||||
pageSize: number,
|
|
||||||
range: [Dayjs, Dayjs],
|
|
||||||
eventType?: PromoMetricEventType,
|
|
||||||
) {
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
qs.set('page', String(page));
|
|
||||||
qs.set('pageSize', String(pageSize));
|
|
||||||
qs.set('dateFrom', range[0].format('YYYY-MM-DD'));
|
|
||||||
qs.set('dateTo', range[1].format('YYYY-MM-DD'));
|
|
||||||
if (eventType) qs.set('eventType', eventType);
|
|
||||||
return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRefId(row: PromoMetricEventItem): string {
|
|
||||||
if (row.orderId) return `订单 ${row.orderId}`;
|
|
||||||
if (row.userId) return `用户 ${row.userId}`;
|
|
||||||
if (row.sessionId) return `会话 ${row.sessionId}`;
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLocation(row: PromoMetricEventItem): string {
|
|
||||||
const parts = [row.ipProvince, row.ipCity].filter(Boolean);
|
|
||||||
if (parts.length) return parts.join(' ');
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
|
||||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
|
|
||||||
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
|
||||||
const [timeline, setTimeline] = useState<PromoMetricTimelineDto | null>(null);
|
|
||||||
const [timelineLoading, setTimelineLoading] = useState(false);
|
|
||||||
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>();
|
|
||||||
const [eventsPage, setEventsPage] = useState(1);
|
|
||||||
const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | null>(null);
|
|
||||||
const [eventsLoading, setEventsLoading] = useState(false);
|
|
||||||
|
|
||||||
const loadTimeline = useCallback(() => {
|
|
||||||
setTimelineLoading(true);
|
|
||||||
return request<PromoMetricTimelineDto>(buildTimelineQs(promoId, range, granularity))
|
|
||||||
.then(setTimeline)
|
|
||||||
.catch((e) => {
|
|
||||||
message.error(e instanceof Error ? e.message : '加载趋势失败');
|
|
||||||
setTimeline(null);
|
|
||||||
})
|
|
||||||
.finally(() => setTimelineLoading(false));
|
|
||||||
}, [promoId, range, granularity]);
|
|
||||||
|
|
||||||
const loadEvents = useCallback(() => {
|
|
||||||
setEventsLoading(true);
|
|
||||||
return request<Paginated<PromoMetricEventItem>>(
|
|
||||||
buildEventsQs(promoId, eventsPage, 20, range, eventType),
|
|
||||||
)
|
|
||||||
.then(setEvents)
|
|
||||||
.catch((e) => {
|
|
||||||
message.error(e instanceof Error ? e.message : '加载事件失败');
|
|
||||||
setEvents(null);
|
|
||||||
})
|
|
||||||
.finally(() => setEventsLoading(false));
|
|
||||||
}, [promoId, eventsPage, range, eventType]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadTimeline();
|
|
||||||
}, [loadTimeline]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadEvents();
|
|
||||||
}, [loadEvents]);
|
|
||||||
|
|
||||||
const chartOption = useMemo<EChartsOption>(() => {
|
|
||||||
const buckets = timeline?.buckets ?? [];
|
|
||||||
const xData = buckets.map((b) => b.key);
|
|
||||||
|
|
||||||
return {
|
|
||||||
tooltip: { trigger: 'axis' },
|
|
||||||
legend: { data: SERIES.map((s) => s.name), bottom: 0 },
|
|
||||||
grid: { left: 48, right: 24, top: 24, bottom: 48 },
|
|
||||||
xAxis: {
|
|
||||||
type: 'category',
|
|
||||||
data: xData,
|
|
||||||
axisLabel: {
|
|
||||||
rotate: granularity === 'hour' ? 45 : 0,
|
|
||||||
formatter: (v: string) => (granularity === 'hour' ? v.slice(5, 16) : v.slice(5)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
yAxis: { type: 'value', minInterval: 1 },
|
|
||||||
series: SERIES.map((s) => ({
|
|
||||||
name: s.name,
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
data: buckets.map((b) => b[s.key]),
|
|
||||||
itemStyle: { color: s.color },
|
|
||||||
markPoint: {
|
|
||||||
symbol: 'pin',
|
|
||||||
symbolSize: 42,
|
|
||||||
data: (() => {
|
|
||||||
const peak = timeline?.peak[s.key];
|
|
||||||
if (!peak || peak.count <= 0) return [];
|
|
||||||
return [{ name: '高峰', coord: [peak.key, peak.count], value: peak.count }];
|
|
||||||
})(),
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}, [timeline, granularity]);
|
|
||||||
|
|
||||||
const columns: ColumnsType<PromoMetricEventItem> = [
|
|
||||||
{
|
|
||||||
title: '时间',
|
|
||||||
dataIndex: 'createdAt',
|
|
||||||
width: 168,
|
|
||||||
render: (v: string) => fmtTime(v),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '类型',
|
|
||||||
dataIndex: 'eventType',
|
|
||||||
width: 108,
|
|
||||||
render: (v: PromoMetricEventType) => PROMO_METRIC_EVENT_LABELS[v],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'ID',
|
|
||||||
key: 'ref',
|
|
||||||
render: (_, row) => formatRefId(row),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'IP',
|
|
||||||
dataIndex: 'clientIp',
|
|
||||||
width: 128,
|
|
||||||
render: (v: string | null) => v ?? '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '地点',
|
|
||||||
key: 'location',
|
|
||||||
width: 120,
|
|
||||||
render: (_, row) => formatLocation(row),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
title="数据趋势"
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
extra={
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
仅统计功能上线后的新事件
|
|
||||||
</Typography.Text>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Space wrap style={{ marginBottom: 16 }}>
|
|
||||||
<DatePicker.RangePicker
|
|
||||||
value={range}
|
|
||||||
onChange={(v) => {
|
|
||||||
if (v?.[0] && v[1]) {
|
|
||||||
setRange([v[0], v[1]]);
|
|
||||||
setEventsPage(1);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
allowClear={false}
|
|
||||||
/>
|
|
||||||
<Radio.Group
|
|
||||||
value={granularity}
|
|
||||||
onChange={(e) => setGranularity(e.target.value as 'day' | 'hour')}
|
|
||||||
optionType="button"
|
|
||||||
buttonStyle="solid"
|
|
||||||
options={[
|
|
||||||
{ label: '按日', value: 'day' },
|
|
||||||
{ label: '按时', value: 'hour' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<ReactECharts
|
|
||||||
option={chartOption}
|
|
||||||
style={{ height: 360 }}
|
|
||||||
showLoading={timelineLoading}
|
|
||||||
notMerge
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}>
|
|
||||||
事件日志
|
|
||||||
</Typography.Title>
|
|
||||||
<Space wrap style={{ marginBottom: 12 }}>
|
|
||||||
<Select
|
|
||||||
allowClear
|
|
||||||
placeholder="全部类型"
|
|
||||||
style={{ width: 160 }}
|
|
||||||
value={eventType}
|
|
||||||
onChange={(v) => {
|
|
||||||
setEventType(v);
|
|
||||||
setEventsPage(1);
|
|
||||||
}}
|
|
||||||
options={Object.entries(PROMO_METRIC_EVENT_LABELS).map(([value, label]) => ({
|
|
||||||
value,
|
|
||||||
label,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
<Table
|
|
||||||
rowKey="id"
|
|
||||||
size="small"
|
|
||||||
columns={columns}
|
|
||||||
dataSource={events?.items ?? []}
|
|
||||||
loading={eventsLoading}
|
|
||||||
pagination={{
|
|
||||||
current: eventsPage,
|
|
||||||
pageSize: events?.pageSize ?? 20,
|
|
||||||
total: events?.total ?? 0,
|
|
||||||
showSizeChanger: false,
|
|
||||||
onChange: (p) => setEventsPage(p),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:3010';
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 5175,
|
port: 5175,
|
||||||
proxy: { '/api': apiTarget },
|
proxy: { '/api': 'http://localhost:3000' },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,188 +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;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isCancelError(msg: string): boolean {
|
|
||||||
return /cancel|取消/i.test(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeUrls(value?: string[]) {
|
|
||||||
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 合伙人端多图批量上传(微信相册可多选) */
|
|
||||||
export default function MultiOssUploadField({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
bizType,
|
|
||||||
mediaType = 'IMAGE',
|
|
||||||
maxCount = 20,
|
|
||||||
disabled,
|
|
||||||
label,
|
|
||||||
}: Props) {
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const pickingRef = useRef(false);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const urls = normalizeUrls(value);
|
|
||||||
const remaining = Math.max(0, maxCount - urls.length);
|
|
||||||
const inWechat = isWechatEnv();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!inWechat) return;
|
|
||||||
void weixinSdk.init().catch(() => {});
|
|
||||||
}, [inWechat]);
|
|
||||||
|
|
||||||
function showUploadError(text: string) {
|
|
||||||
setError(text);
|
|
||||||
toastError(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadFiles(files: File[]) {
|
|
||||||
const current = normalizeUrls(value);
|
|
||||||
const room = Math.max(0, maxCount - current.length);
|
|
||||||
const picked = files.slice(0, room);
|
|
||||||
if (!picked.length) {
|
|
||||||
showUploadError(`最多 ${maxCount} 张`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploading(true);
|
|
||||||
setError('');
|
|
||||||
const appended: string[] = [];
|
|
||||||
try {
|
|
||||||
for (const file of picked) {
|
|
||||||
if (!file.size) continue;
|
|
||||||
const result = await enqueueUpload(() => uploadFileToOss(file, { bizType, mediaType }));
|
|
||||||
appended.push(result.url);
|
|
||||||
}
|
|
||||||
if (appended.length) {
|
|
||||||
onChange?.([...current, ...appended]);
|
|
||||||
toastSuccess(`已上传 ${appended.length} 张`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
showUploadError(e instanceof Error ? e.message : '上传失败');
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
if (inputRef.current) inputRef.current.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickWechat() {
|
|
||||||
if (pickingRef.current || uploading || disabled || remaining <= 0) return;
|
|
||||||
pickingRef.current = true;
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await weixinSdk.init();
|
|
||||||
const files = await weixinSdk.chooseImages({
|
|
||||||
count: Math.min(remaining, 9),
|
|
||||||
sourceType: ['album', 'camera'],
|
|
||||||
});
|
|
||||||
if (!files?.length) return;
|
|
||||||
await uploadFiles(files);
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
|
||||||
if (isCancelError(msg)) return;
|
|
||||||
const formatted = formatChooseImageFailMessage(msg) || msg;
|
|
||||||
showUploadError(formatted);
|
|
||||||
inputRef.current?.click();
|
|
||||||
} finally {
|
|
||||||
pickingRef.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeAt(index: number) {
|
|
||||||
if (disabled) return;
|
|
||||||
onChange?.(urls.filter((_, i) => i !== index));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="partner-oss-upload">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
className="partner-oss-upload-input"
|
|
||||||
disabled={disabled || uploading || remaining <= 0}
|
|
||||||
onChange={(e) => {
|
|
||||||
const list = Array.from(e.target.files ?? []);
|
|
||||||
if (list.length) void uploadFiles(list);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{urls.length > 0 ? (
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
|
||||||
{urls.map((url, index) => (
|
|
||||||
<div key={`${url}-${index}`} style={{ position: 'relative', width: 88, height: 88 }}>
|
|
||||||
<img
|
|
||||||
src={url}
|
|
||||||
alt=""
|
|
||||||
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
|
|
||||||
/>
|
|
||||||
{!disabled ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="partner-packages-remove"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 2,
|
|
||||||
right: 2,
|
|
||||||
margin: 0,
|
|
||||||
padding: '2px 6px',
|
|
||||||
fontSize: 12,
|
|
||||||
background: 'rgba(0,0,0,0.55)',
|
|
||||||
color: '#fff',
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
onClick={() => removeAt(index)}
|
|
||||||
>
|
|
||||||
删
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="partner-upload-dashed partner-upload-dashed--compact"
|
|
||||||
disabled={disabled || uploading || remaining <= 0}
|
|
||||||
onClick={() => {
|
|
||||||
if (inWechat) void pickWechat();
|
|
||||||
else inputRef.current?.click();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
|
|
||||||
{uploading ? 'hourglass_top' : 'add_a_photo'}
|
|
||||||
</span>
|
|
||||||
<span className="text-primary" style={{ fontWeight: 500 }}>
|
|
||||||
{uploading
|
|
||||||
? '上传中…'
|
|
||||||
: remaining <= 0
|
|
||||||
? `已达上限 ${maxCount}`
|
|
||||||
: label ?? `批量上传(${urls.length}/${maxCount})`}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{error ? (
|
|
||||||
<p className="partner-form-error" role="alert">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,8 @@ type OssUploadFieldProps = {
|
|||||||
onWechatReadyChange?: (ready: boolean) => void;
|
onWechatReadyChange?: (ready: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_MAX_MB = 10;
|
||||||
|
|
||||||
function isCancelError(msg: string): boolean {
|
function isCancelError(msg: string): boolean {
|
||||||
return /cancel|取消/i.test(msg);
|
return /cancel|取消/i.test(msg);
|
||||||
}
|
}
|
||||||
@@ -77,6 +79,10 @@ export default function OssUploadField({
|
|||||||
showUploadError('图片读取失败,请重新选择');
|
showUploadError('图片读取失败,请重新选择');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (file.size > DEFAULT_MAX_MB * 1024 * 1024) {
|
||||||
|
showUploadError(`文件不能超过 ${DEFAULT_MAX_MB}MB`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const result = await uploadFileToOss(file, { bizType, mediaType });
|
const result = await uploadFileToOss(file, { bizType, mediaType });
|
||||||
onChange?.(result.url);
|
onChange?.(result.url);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
@@ -131,24 +131,12 @@ export default function StorePackagesForm({ items, onChange, disabled, embedded
|
|||||||
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import { apiBase, request } from './api';
|
import { apiBase, request } from './api';
|
||||||
import {
|
|
||||||
compressImageFileIfNeeded,
|
|
||||||
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
|
||||||
formatOssMaxSizeMb,
|
|
||||||
} from '@dukang/shared-ui/compressImage';
|
|
||||||
|
|
||||||
const UPLOAD_TIMEOUT_MS = 120_000;
|
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
@@ -16,23 +11,13 @@ export type UploadFileResult = {
|
|||||||
mock: boolean;
|
mock: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
|
|
||||||
if (mediaType !== 'IMAGE') return file;
|
|
||||||
const compressed = await compressImageFileIfNeeded(file);
|
|
||||||
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
|
|
||||||
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
|
|
||||||
}
|
|
||||||
return compressed;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadFileToOssInner(
|
async function uploadFileToOssInner(
|
||||||
file: File,
|
file: File,
|
||||||
options: { bizType: string; mediaType?: OssMediaType },
|
options: { bizType: string; mediaType?: OssMediaType },
|
||||||
): Promise<UploadFileResult> {
|
): Promise<UploadFileResult> {
|
||||||
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||||
const prepared = await prepareUploadFile(file, mediaType);
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', prepared);
|
formData.append('file', file);
|
||||||
formData.append('bizType', options.bizType);
|
formData.append('bizType', options.bizType);
|
||||||
formData.append('mediaType', mediaType);
|
formData.append('mediaType', mediaType);
|
||||||
|
|
||||||
|
|||||||
@@ -48,19 +48,6 @@ export async function canPartnerUseWechatLogin(options: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 短信登录/发码前:校验手机号对应合伙人存在且未暂停 */
|
|
||||||
export async function checkPartnerPhoneForLogin(phone: string): Promise<PartnerPhoneCheckResponse> {
|
|
||||||
const normalized = phone.trim();
|
|
||||||
if (!/^1\d{10}$/.test(normalized)) {
|
|
||||||
throw new Error('请输入正确的手机号');
|
|
||||||
}
|
|
||||||
return request<PartnerPhoneCheckResponse>('PARTNER_H5', '/partner/auth/phone/check', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ phone: normalized }),
|
|
||||||
silent: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||||||
return request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config');
|
return request<ClientRuntimeConfig>('PARTNER_H5', '/common/client-config');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ import {
|
|||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { partnerHomePath } from '../lib/partnerAccess';
|
import { partnerHomePath } from '../lib/partnerAccess';
|
||||||
import type { PartnerPhoneCheckResponse } from '@dukang/shared-types';
|
|
||||||
import {
|
import {
|
||||||
bindPartnerWechatAfterSmsLogin,
|
bindPartnerWechatAfterSmsLogin,
|
||||||
checkPartnerPhoneForLogin,
|
|
||||||
fetchClientConfig,
|
fetchClientConfig,
|
||||||
loginPartnerWithWechat,
|
loginPartnerWithWechat,
|
||||||
} from '../lib/wechat-auth';
|
} from '../lib/wechat-auth';
|
||||||
@@ -71,17 +69,14 @@ function formatPartnerError(e: unknown): string {
|
|||||||
if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) {
|
if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) {
|
||||||
return '未找到合伙人账号';
|
return '未找到合伙人账号';
|
||||||
}
|
}
|
||||||
if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) {
|
if (text.includes('合伙人账号已停用')) {
|
||||||
return '合伙人账号已暂停,无法登录';
|
return '合伙人账号已停用';
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatWechatError(e: unknown): string {
|
function formatWechatError(e: unknown): string {
|
||||||
const text = e instanceof Error ? e.message : '微信登录失败';
|
const text = e instanceof Error ? e.message : '微信登录失败';
|
||||||
if (text.includes('合伙人账号已停用') || text.includes('账号已停用')) {
|
|
||||||
return '合伙人账号已暂停,无法登录';
|
|
||||||
}
|
|
||||||
if (text.includes('首次登录') || text.includes('手机验证码')) {
|
if (text.includes('首次登录') || text.includes('手机验证码')) {
|
||||||
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
|
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
|
||||||
}
|
}
|
||||||
@@ -143,21 +138,11 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
async function sendCode() {
|
async function sendCode() {
|
||||||
if (!ensureAgreed()) return;
|
if (!ensureAgreed()) return;
|
||||||
const trimmedPhone = phone.trim();
|
|
||||||
if (!trimmedPhone) {
|
|
||||||
setMsg('请输入手机号');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!/^1\d{10}$/.test(trimmedPhone)) {
|
|
||||||
setMsg('请输入正确的手机号');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
await checkPartnerPhoneForLogin(trimmedPhone);
|
|
||||||
await request('PARTNER_H5', '/partner/auth/sms/send', {
|
await request('PARTNER_H5', '/partner/auth/sms/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone: trimmedPhone, scene: 'PARTNER_LOGIN' }),
|
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
|
||||||
silent: true,
|
silent: true,
|
||||||
});
|
});
|
||||||
setMsg('验证码已发送');
|
setMsg('验证码已发送');
|
||||||
@@ -183,27 +168,12 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
if (!ensureAgreed()) return;
|
if (!ensureAgreed()) return;
|
||||||
const trimmedPhone = phone.trim();
|
|
||||||
const trimmedCode = code.trim();
|
|
||||||
if (!trimmedPhone) {
|
|
||||||
setMsg('请输入手机号');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!/^1\d{10}$/.test(trimmedPhone)) {
|
|
||||||
setMsg('请输入正确的手机号');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!trimmedCode) {
|
|
||||||
setMsg('请输入验证码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
await checkPartnerPhoneForLogin(trimmedPhone);
|
|
||||||
const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', {
|
const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }),
|
body: JSON.stringify({ phone, code }),
|
||||||
silent: true,
|
silent: true,
|
||||||
});
|
});
|
||||||
saveRememberedSession(data);
|
saveRememberedSession(data);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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 { toastError, toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
@@ -41,6 +40,8 @@ import {
|
|||||||
|
|
||||||
validateStoreStep3,
|
validateStoreStep3,
|
||||||
|
|
||||||
|
patchEnvPhotoAt,
|
||||||
|
addEnvPhotoSlot,
|
||||||
MIN_ENV_PHOTO_COUNT,
|
MIN_ENV_PHOTO_COUNT,
|
||||||
} from '../lib/storeDraft';
|
} from '../lib/storeDraft';
|
||||||
import StorePackagesForm from '../components/StorePackagesForm';
|
import StorePackagesForm from '../components/StorePackagesForm';
|
||||||
@@ -249,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);
|
||||||
@@ -940,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>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -410,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"
|
||||||
|
|||||||
@@ -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 : '加载失败'))
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
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}
|
||||||
@@ -157,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) => {
|
||||||
@@ -193,23 +145,20 @@ 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
fetchClientConfig,
|
fetchClientConfig,
|
||||||
handleShopWechatCallback,
|
handleShopWechatCallback,
|
||||||
handleShopWechatCallbackOnce,
|
|
||||||
handleShopWechatLoginResult,
|
handleShopWechatLoginResult,
|
||||||
isShopWechatUnboundError,
|
isShopWechatUnboundError,
|
||||||
SHOP_WX_NEED_PHONE_LOGIN_MSG,
|
SHOP_WX_NEED_PHONE_LOGIN_MSG,
|
||||||
@@ -80,7 +79,7 @@ export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const config = await fetchClientConfig();
|
const config = await fetchClientConfig();
|
||||||
if (isWxAuthorizeEnabled(config)) {
|
if (isWxAuthorizeEnabled(config)) {
|
||||||
const result = await handleShopWechatCallbackOnce();
|
const result = await handleShopWechatCallback();
|
||||||
if (result && !cancelled) {
|
if (result && !cancelled) {
|
||||||
const session = handleShopWechatLoginResult(result);
|
const session = handleShopWechatLoginResult(result);
|
||||||
if (session) applySession(session);
|
if (session) applySession(session);
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { isIosDevice } from '@dukang/weixin-sdk';
|
|
||||||
|
|
||||||
/** 扫码前发起 OAuth 时标记,回跳后在首页续扫 */
|
|
||||||
export const SHOP_PENDING_SCAN_KEY = 'shop_pending_scan';
|
|
||||||
|
|
||||||
/** 短信登录后绑定微信等 OAuth 回跳:下次手动扫码加长预热(不自动打开相机) */
|
|
||||||
export const SHOP_SCAN_WARMUP_KEY = 'shop_scan_warmup';
|
|
||||||
|
|
||||||
export function markPendingScanAfterAuth(): void {
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(SHOP_PENDING_SCAN_KEY, '1');
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function peekPendingScanAfterAuth(): boolean {
|
|
||||||
try {
|
|
||||||
return sessionStorage.getItem(SHOP_PENDING_SCAN_KEY) === '1';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearPendingScanAfterAuth(): void {
|
|
||||||
try {
|
|
||||||
sessionStorage.removeItem(SHOP_PENDING_SCAN_KEY);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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 离线校验更慢) */
|
|
||||||
export function getPostAuthScanDelayMs(): number {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { apiBase } from './api';
|
import { apiBase } from './api';
|
||||||
import { getStoreProfile } from './api';
|
import { getStoreProfile } from './api';
|
||||||
import {
|
|
||||||
compressImageFileIfNeeded,
|
|
||||||
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
|
||||||
formatOssMaxSizeMb,
|
|
||||||
} from '@dukang/shared-ui/compressImage';
|
|
||||||
|
|
||||||
const UPLOAD_TIMEOUT_MS = 120_000;
|
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
@@ -21,12 +16,8 @@ export type RegisteredResource = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> {
|
export async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> {
|
||||||
const compressed = await compressImageFileIfNeeded(file);
|
|
||||||
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
|
|
||||||
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
|
|
||||||
}
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', compressed);
|
formData.append('file', file);
|
||||||
formData.append('bizType', bizType);
|
formData.append('bizType', bizType);
|
||||||
formData.append('mediaType', 'IMAGE');
|
formData.append('mediaType', 'IMAGE');
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
@@ -139,20 +138,6 @@ export async function handleShopWechatCallback(): Promise<WechatLoginResult | nu
|
|||||||
return weixinSdk.handleOAuthCallback();
|
return weixinSdk.handleOAuthCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
let oauthCallbackInflight: Promise<WechatLoginResult | null> | null = null;
|
|
||||||
|
|
||||||
/** 全局单例:避免 Context 与页面 effect 重复消费 OAuth code */
|
|
||||||
export async function handleShopWechatCallbackOnce(): Promise<WechatLoginResult | null> {
|
|
||||||
if (!isWechatEnv()) return null;
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
if (!params.get('code')) return null;
|
|
||||||
if (oauthCallbackInflight) return oauthCallbackInflight;
|
|
||||||
oauthCallbackInflight = handleShopWechatCallback().finally(() => {
|
|
||||||
oauthCallbackInflight = null;
|
|
||||||
});
|
|
||||||
return oauthCallbackInflight;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 微信一键登录(已绑定微信的门店账号免验证码)。
|
* 微信一键登录(已绑定微信的门店账号免验证码)。
|
||||||
* 返回 session = 已登录;void = 已跳转授权页等待回调。
|
* 返回 session = 已登录;void = 已跳转授权页等待回调。
|
||||||
@@ -163,19 +148,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 +166,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,285 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
|
||||||
import { isIosDevice, 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 {
|
||||||
|
|
||||||
authorizeShopWechat,
|
authorizeShopWechat,
|
||||||
|
|
||||||
checkNeedsWechatAuth,
|
checkNeedsWechatAuth,
|
||||||
|
|
||||||
fetchShopAccount,
|
fetchShopAccount,
|
||||||
|
handleShopWechatCallback,
|
||||||
|
handleShopWechatLoginResult,
|
||||||
} from '../lib/wechat-auth';
|
} from '../lib/wechat-auth';
|
||||||
|
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||||
import {
|
|
||||||
|
|
||||||
clearPendingScanAfterAuth,
|
|
||||||
|
|
||||||
consumeScanWarmupAfterAuth,
|
|
||||||
|
|
||||||
getPostAuthScanDelayMs,
|
|
||||||
|
|
||||||
markPendingScanAfterAuth,
|
|
||||||
|
|
||||||
peekPendingScanAfterAuth,
|
|
||||||
|
|
||||||
} from '../lib/shop-scan-auth';
|
|
||||||
|
|
||||||
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
||||||
|
|
||||||
import { useStorePageView } from '../lib/usePageView';
|
import { useStorePageView } from '../lib/usePageView';
|
||||||
|
|
||||||
import { trackStore } from '../lib/analytics';
|
import { trackStore } from '../lib/analytics';
|
||||||
|
|
||||||
|
const PENDING_SCAN_KEY = 'shop_pending_scan';
|
||||||
|
|
||||||
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 });
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatScanError(e: unknown): 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|config:fail|signature/i.test(msg)) {
|
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
|
||||||
|
|
||||||
return '微信扫码签名校验失败,请刷新页面或重新授权微信后重试';
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
|
||||||
if (isScanPermissionWarmupError(msg)) {
|
return '微信权限校验尚未完成,请等待 1~2 秒后再次点击扫码(首次绑定微信时较常见)';
|
||||||
|
|
||||||
if (opts?.afterAuth) {
|
|
||||||
|
|
||||||
return '微信授权后扫码仍未就绪,请刷新页面或重新授权微信';
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return '微信扫码能力未就绪,请刷新页面或重新授权微信';
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return msg;
|
return msg;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isScanRecoverableError(msg: string): boolean {
|
|
||||||
|
|
||||||
return isScanPermissionWarmupError(msg) || /签名校验失败|扫码能力未就绪|请刷新页面/i.test(msg);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
|
||||||
useStorePageView('store_home_view');
|
useStorePageView('store_home_view');
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { applySession } = useStoreSession();
|
||||||
const { ready, authenticated } = useStoreSession();
|
const [searchParams, setSearchParams] = 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);
|
const [scanning, setScanning] = useState(false);
|
||||||
|
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||||
|
|
||||||
const [authModalMode, setAuthModalMode] = useState<'bind' | 'recover'>('bind');
|
|
||||||
|
|
||||||
const [authLoading, setAuthLoading] = useState(false);
|
const [authLoading, setAuthLoading] = useState(false);
|
||||||
|
|
||||||
const [authError, setAuthError] = useState('');
|
const [authError, setAuthError] = useState('');
|
||||||
|
|
||||||
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);
|
setDash(d);
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
.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);
|
||||||
|
|
||||||
window.addEventListener('focus', onResume);
|
window.addEventListener('focus', onResume);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
||||||
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]);
|
||||||
|
|
||||||
|
async function runScan(opts?: { postAuthWarmup?: boolean }) {
|
||||||
|
|
||||||
const runScan = useCallback(
|
|
||||||
|
|
||||||
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) {
|
|
||||||
|
|
||||||
setScanMsg('');
|
setScanMsg('');
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 授权回跳后强制重签,避免沿用带 code 的旧签名态
|
||||||
// iOS / OAuth 回跳后须重新 wx.config(签名用入场 URL)
|
if (opts?.postAuthWarmup) {
|
||||||
if (opts?.postAuthWarmup || isIosDevice()) {
|
|
||||||
|
|
||||||
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));
|
||||||
const tip = formatScanError(e, { afterAuth: opts?.postAuthWarmup });
|
|
||||||
|
|
||||||
setScanMsg(tip);
|
|
||||||
|
|
||||||
if (isScanRecoverableError(tip)) {
|
|
||||||
|
|
||||||
setAuthModalMode('recover');
|
|
||||||
|
|
||||||
setAuthError(tip);
|
|
||||||
|
|
||||||
setAuthModalOpen(true);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
|
||||||
setScanning(false);
|
setScanning(false);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
|
||||||
|
|
||||||
[loadDashboard, navigate],
|
|
||||||
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// OAuth 由 StoreSessionContext 单例处理;此处仅在授权完成后续扫
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isWechatEnv() || !searchParams.get('code')) return;
|
||||||
if (!ready || !authenticated || !isWechatEnv()) return;
|
void handleShopWechatCallback()
|
||||||
|
.then((result) => {
|
||||||
if (searchParams.get('code')) return;
|
if (!result) return;
|
||||||
|
const session = handleShopWechatLoginResult(result);
|
||||||
if (!peekPendingScanAfterAuth() || pendingScanStartedRef.current) return;
|
if (session) {
|
||||||
|
applySession(session);
|
||||||
|
}
|
||||||
|
|
||||||
pendingScanStartedRef.current = true;
|
|
||||||
|
|
||||||
clearPendingScanAfterAuth();
|
|
||||||
|
|
||||||
setAuthModalOpen(false);
|
setAuthModalOpen(false);
|
||||||
|
|
||||||
setAuthLoading(false);
|
|
||||||
|
|
||||||
setAuthError('');
|
setAuthError('');
|
||||||
|
// 先清 OAuth 参数再扫,保证 JSSDK 签名 URL 与当前页一致
|
||||||
setScanMsg('微信授权成功,正在准备扫码…');
|
stripOAuthParamsFromLocation();
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
|
||||||
|
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||||
const timer = window.setTimeout(() => {
|
if (shouldScan) {
|
||||||
|
// OAuth 回跳后微信权限离线校验未完成;稍候再扫,失败可再点一次
|
||||||
void runScan({ postAuthWarmup: true });
|
window.setTimeout(() => void runScan({ postAuthWarmup: true }), 400);
|
||||||
|
}
|
||||||
}, getPostAuthScanDelayMs());
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
||||||
|
});
|
||||||
return () => window.clearTimeout(timer);
|
}, [searchParams, applySession, setSearchParams]);
|
||||||
|
|
||||||
}, [ready, authenticated, searchParams, runScan]);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function handleScan() {
|
async function handleScan() {
|
||||||
|
|
||||||
setScanMsg('');
|
setScanMsg('');
|
||||||
|
|
||||||
if (!isWechatEnv()) {
|
if (!isWechatEnv()) {
|
||||||
|
|
||||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const profile = await fetchShopAccount();
|
const profile = await fetchShopAccount();
|
||||||
|
|
||||||
if (await checkNeedsWechatAuth(profile)) {
|
if (await checkNeedsWechatAuth(profile)) {
|
||||||
|
|
||||||
pendingScanStartedRef.current = false;
|
|
||||||
|
|
||||||
setAuthModalMode('bind');
|
|
||||||
|
|
||||||
setAuthError('');
|
|
||||||
|
|
||||||
setAuthModalOpen(true);
|
setAuthModalOpen(true);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
await runScan();
|
||||||
const needWarmup = consumeScanWarmupAfterAuth();
|
|
||||||
|
|
||||||
await runScan(needWarmup ? { postAuthWarmup: true } : undefined);
|
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
||||||
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
|
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function startWechatAuth() {
|
async function startWechatAuth() {
|
||||||
|
|
||||||
setAuthLoading(true);
|
setAuthLoading(true);
|
||||||
|
|
||||||
setAuthError('');
|
setAuthError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
|
||||||
pendingScanStartedRef.current = false;
|
|
||||||
|
|
||||||
markPendingScanAfterAuth();
|
|
||||||
|
|
||||||
await authorizeShopWechat();
|
await authorizeShopWechat();
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||||
clearPendingScanAfterAuth();
|
|
||||||
|
|
||||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
||||||
|
|
||||||
setAuthLoading(false);
|
setAuthLoading(false);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const store = dash?.store as Record<string, unknown> | undefined;
|
const store = dash?.store as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
||||||
|
|
||||||
const status = String(store?.status || '');
|
const status = String(store?.status || '');
|
||||||
|
|
||||||
const open = status === 'OPEN';
|
const open = status === 'OPEN';
|
||||||
|
|
||||||
const hoursParts: string[] = [];
|
const hoursParts: string[] = [];
|
||||||
|
|
||||||
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
|
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
|
||||||
|
|
||||||
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
|
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
|
||||||
|
|
||||||
const hoursText = hoursParts.length ? hoursParts.join(',') : '10:00 - 22:00';
|
const hoursText = hoursParts.length ? hoursParts.join(',') : '10:00 - 22:00';
|
||||||
|
|
||||||
const statusText =
|
const statusText =
|
||||||
|
|
||||||
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店';
|
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
|
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
|
||||||
|
|
||||||
<header className="shop-home-header">
|
<header className="shop-home-header">
|
||||||
|
|
||||||
<h1 className="app-page-title">门店管理中心</h1>
|
<h1 className="app-page-title">门店管理中心</h1>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="shop-home-content">
|
<div className="shop-home-content">
|
||||||
|
|
||||||
<section className="shop-home-hero">
|
<section className="shop-home-hero">
|
||||||
|
|
||||||
<div className="shop-home-hero-store">
|
<div className="shop-home-hero-store">
|
||||||
|
|
||||||
<span className="material-symbols-outlined shop-fill-icon">store</span>
|
<span className="material-symbols-outlined shop-fill-icon">store</span>
|
||||||
|
|
||||||
<h2>{String(store?.name || '门店')}</h2>
|
<h2>{String(store?.name || '门店')}</h2>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shop-home-stats">
|
<div className="shop-home-stats">
|
||||||
|
|
||||||
<div className="shop-home-stat">
|
<div className="shop-home-stat">
|
||||||
|
|
||||||
<p className="shop-home-stat-label">今日核销笔数</p>
|
<p className="shop-home-stat-label">今日核销笔数</p>
|
||||||
|
|
||||||
<p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</p>
|
<p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</p>
|
||||||
|
|
||||||
<p className="shop-home-stat-sub">
|
<p className="shop-home-stat-sub">
|
||||||
|
|
||||||
扫码 {Number(dash?.todayScanCount || 0)} · 手机号 {Number(dash?.todayPhoneCount || 0)}
|
扫码 {Number(dash?.todayScanCount || 0)} · 手机号 {Number(dash?.todayPhoneCount || 0)}
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shop-home-stat">
|
<div className="shop-home-stat">
|
||||||
|
|
||||||
<p className="shop-home-stat-label">今日到账金额</p>
|
<p className="shop-home-stat-label">今日到账金额</p>
|
||||||
|
|
||||||
<p className="shop-home-stat-value">
|
<p className="shop-home-stat-value">
|
||||||
|
|
||||||
<span style={{ fontSize: 18 }}>¥</span>
|
<span style={{ fontSize: 18 }}>¥</span>
|
||||||
|
|
||||||
{formatMoney(Number(dash?.todayAmount || 0))}
|
{formatMoney(Number(dash?.todayAmount || 0))}
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<section className="shop-home-scan">
|
<section className="shop-home-scan">
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
||||||
type="button"
|
type="button"
|
||||||
|
|
||||||
className="shop-home-scan-btn"
|
className="shop-home-scan-btn"
|
||||||
|
|
||||||
disabled={scanning}
|
disabled={scanning}
|
||||||
|
|
||||||
onClick={() => void handleScan()}
|
onClick={() => void handleScan()}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
<span className="material-symbols-outlined">qr_code_scanner</span>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
|
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
|
||||||
|
|
||||||
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
|
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
|
||||||
|
|
||||||
<Link to="/redeem/phone" className="shop-home-phone-link">
|
<Link to="/redeem/phone" className="shop-home-phone-link">
|
||||||
|
|
||||||
<span className="material-symbols-outlined">smartphone</span>
|
<span className="material-symbols-outlined">smartphone</span>
|
||||||
|
|
||||||
手机号核销
|
手机号核销
|
||||||
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<section className="shop-home-status">
|
<section className="shop-home-status">
|
||||||
|
|
||||||
<div className="shop-home-status-left">
|
<div className="shop-home-status-left">
|
||||||
|
|
||||||
<div className={`shop-home-status-icon${open ? '' : ' closed'}`}>
|
<div className={`shop-home-status-icon${open ? '' : ' closed'}`}>
|
||||||
|
|
||||||
<span className="material-symbols-outlined shop-fill-icon">schedule</span>
|
<span className="material-symbols-outlined shop-fill-icon">schedule</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
<p className="shop-home-status-title">营业状态</p>
|
<p className="shop-home-status-title">营业状态</p>
|
||||||
|
|
||||||
<p className="shop-home-status-sub">{statusText}</p>
|
<p className="shop-home-status-sub">{statusText}</p>
|
||||||
|
|
||||||
<p className="shop-home-status-sub">营业时间: {hoursText}</p>
|
<p className="shop-home-status-sub">营业时间: {hoursText}</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="shop-home-switch" onClick={() => navigate('/status')}>
|
<label className="shop-home-switch" onClick={() => navigate('/status')}>
|
||||||
|
|
||||||
<input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} />
|
<input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} />
|
||||||
|
|
||||||
<span className="shop-home-switch-track" />
|
<span className="shop-home-switch-track" />
|
||||||
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|
||||||
<div className="shop-home-records-head">
|
<div className="shop-home-records-head">
|
||||||
|
|
||||||
<h3 className="shop-home-records-title">核销记录</h3>
|
<h3 className="shop-home-records-title">核销记录</h3>
|
||||||
|
|
||||||
<Link to="/records" className="shop-home-records-link">
|
<Link to="/records" className="shop-home-records-link">
|
||||||
|
|
||||||
查看全部
|
查看全部
|
||||||
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
|
||||||
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shop-home-record-list">
|
<div className="shop-home-record-list">
|
||||||
|
|
||||||
{recent.length === 0 && (
|
{recent.length === 0 && (
|
||||||
|
|
||||||
<p className="shop-home-status-sub" style={{ textAlign: 'center', padding: '16px 0' }}>暂无核销记录</p>
|
<p className="shop-home-status-sub" style={{ textAlign: 'center', padding: '16px 0' }}>暂无核销记录</p>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recent.map((r) => (
|
{recent.map((r) => (
|
||||||
|
|
||||||
<div key={String(r.id)} className="shop-home-record-item">
|
<div key={String(r.id)} className="shop-home-record-item">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
<p className="shop-home-record-time">核销时间</p>
|
<p className="shop-home-record-time">核销时间</p>
|
||||||
|
|
||||||
<p className="shop-home-record-value">
|
<p className="shop-home-record-value">
|
||||||
|
|
||||||
{new Date(String(r.createdAt)).toLocaleString('zh-CN')}
|
{new Date(String(r.createdAt)).toLocaleString('zh-CN')}
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p>
|
<p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<WechatScanAuthModal
|
<WechatScanAuthModal
|
||||||
|
|
||||||
open={authModalOpen}
|
open={authModalOpen}
|
||||||
|
|
||||||
mode={authModalMode}
|
|
||||||
|
|
||||||
loading={authLoading}
|
loading={authLoading}
|
||||||
|
|
||||||
error={authError}
|
error={authError}
|
||||||
|
|
||||||
onAuthorize={() => void startWechatAuth()}
|
onAuthorize={() => void startWechatAuth()}
|
||||||
|
|
||||||
onRefresh={() => {
|
|
||||||
|
|
||||||
window.location.reload();
|
|
||||||
|
|
||||||
}}
|
|
||||||
|
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
|
|
||||||
setAuthModalOpen(false);
|
setAuthModalOpen(false);
|
||||||
|
|
||||||
setAuthModalMode('bind');
|
|
||||||
|
|
||||||
setAuthError('');
|
setAuthError('');
|
||||||
|
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||||
clearPendingScanAfterAuth();
|
|
||||||
|
|
||||||
pendingScanStartedRef.current = false;
|
|
||||||
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</PullToRefresh>
|
</PullToRefresh>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
consumeShopWechatLoginHint,
|
consumeShopWechatLoginHint,
|
||||||
fetchClientConfig,
|
fetchClientConfig,
|
||||||
formatShopWechatError,
|
formatShopWechatError,
|
||||||
handleShopWechatCallbackOnce,
|
handleShopWechatCallback,
|
||||||
handleShopWechatLoginResult,
|
handleShopWechatLoginResult,
|
||||||
loginShopWithWechat,
|
loginShopWithWechat,
|
||||||
} from '../lib/wechat-auth';
|
} from '../lib/wechat-auth';
|
||||||
@@ -88,7 +88,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
|
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
|
||||||
void handleShopWechatCallbackOnce()
|
void handleShopWechatCallback()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
const session = handleShopWechatLoginResult(result);
|
const session = handleShopWechatLoginResult(result);
|
||||||
@@ -140,28 +140,18 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
if (!ensureAgreed()) return;
|
if (!ensureAgreed()) return;
|
||||||
const trimmedPhone = phone.trim();
|
|
||||||
const trimmedCode = code.trim();
|
|
||||||
if (!trimmedPhone) {
|
|
||||||
setMsg('phone should not be empty');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!trimmedCode) {
|
|
||||||
setMsg('code should not be empty');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', {
|
const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone: trimmedPhone, code: trimmedCode }),
|
body: JSON.stringify({ phone, code }),
|
||||||
});
|
});
|
||||||
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);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
authorizeShopWechat,
|
authorizeShopWechat,
|
||||||
fetchClientConfig,
|
fetchClientConfig,
|
||||||
fetchShopAccount,
|
fetchShopAccount,
|
||||||
handleShopWechatCallbackOnce,
|
handleShopWechatCallback,
|
||||||
handleShopWechatLoginResult,
|
handleShopWechatLoginResult,
|
||||||
} from '../lib/wechat-auth';
|
} from '../lib/wechat-auth';
|
||||||
import { isWechatEnv } from '../lib/weixin';
|
import { isWechatEnv } from '../lib/weixin';
|
||||||
@@ -45,7 +45,7 @@ export default function MinePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
|
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
|
||||||
void handleShopWechatCallbackOnce()
|
void handleShopWechatCallback()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
const session = handleShopWechatLoginResult(result);
|
const session = handleShopWechatLoginResult(result);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
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,9 +1,4 @@
|
|||||||
import { apiBase } from './api';
|
import { apiBase } from './api';
|
||||||
import {
|
|
||||||
compressImageFileIfNeeded,
|
|
||||||
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
|
||||||
formatOssMaxSizeMb,
|
|
||||||
} from '@dukang/shared-ui/compressImage';
|
|
||||||
|
|
||||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||||
|
|
||||||
@@ -20,15 +15,8 @@ export async function uploadFileToOss(
|
|||||||
options: { bizType: string; mediaType?: OssMediaType },
|
options: { bizType: string; mediaType?: OssMediaType },
|
||||||
): Promise<UploadFileResult> {
|
): Promise<UploadFileResult> {
|
||||||
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||||
let prepared = file;
|
|
||||||
if (mediaType === 'IMAGE') {
|
|
||||||
prepared = await compressImageFileIfNeeded(file);
|
|
||||||
if (prepared.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
|
|
||||||
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', prepared);
|
formData.append('file', file);
|
||||||
formData.append('bizType', options.bizType);
|
formData.append('bizType', options.bizType);
|
||||||
formData.append('mediaType', mediaType);
|
formData.append('mediaType', mediaType);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
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": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ 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',
|
||||||
'pages/orders/index',
|
'pages/orders/index',
|
||||||
'pages/order-detail/index',
|
'pages/order-detail/index',
|
||||||
'pages/order-logistics/index',
|
|
||||||
'pages/pickup-receive/index',
|
'pages/pickup-receive/index',
|
||||||
'pages/addresses/index',
|
'pages/addresses/index',
|
||||||
'pages/address-edit/index',
|
'pages/address-edit/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 |
@@ -1,44 +0,0 @@
|
|||||||
import { Text } from '@tarojs/components';
|
|
||||||
import Taro from '@tarojs/taro';
|
|
||||||
import { splitLogisticsText } from '../lib/order-logistics-utils';
|
|
||||||
import { toast } from '../lib/api';
|
|
||||||
|
|
||||||
type LogisticsRichTextProps = {
|
|
||||||
text: string;
|
|
||||||
className?: string;
|
|
||||||
phoneClassName?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LogisticsRichText({
|
|
||||||
text,
|
|
||||||
className = '',
|
|
||||||
phoneClassName = 'order-logistics-phone-link',
|
|
||||||
}: LogisticsRichTextProps) {
|
|
||||||
const segments = splitLogisticsText(text);
|
|
||||||
|
|
||||||
function dial(phone: string) {
|
|
||||||
const normalized = phone.replace(/-/g, '');
|
|
||||||
Taro.makePhoneCall({ phoneNumber: normalized }).catch(() => toast('无法拨打电话'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text className={className}>
|
|
||||||
{segments.map((segment, index) =>
|
|
||||||
segment.type === 'phone' ? (
|
|
||||||
<Text
|
|
||||||
key={`${segment.value}-${index}`}
|
|
||||||
className={phoneClassName}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
dial(segment.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{segment.value}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text key={`${index}-text`}>{segment.value}</Text>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,11 @@ 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 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 +29,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 +41,7 @@ export default function ProductCarousel({
|
|||||||
<Image
|
<Image
|
||||||
className={`${prefix}-image`}
|
className={`${prefix}-image`}
|
||||||
src={src}
|
src={src}
|
||||||
mode={isContain ? 'aspectFit' : 'aspectFill'}
|
mode="aspectFill"
|
||||||
alt={alt}
|
alt={alt}
|
||||||
onClick={previewable ? () => previewAt(index) : undefined}
|
onClick={previewable ? () => previewAt(index) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useEffect, useRef } from 'react';
|
|||||||
import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav';
|
import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav';
|
||||||
import { toast } from '../lib/api';
|
import { toast } from '../lib/api';
|
||||||
import { capturePromoSceneAndTouchScan } from '../lib/promo';
|
import { capturePromoSceneAndTouchScan } from '../lib/promo';
|
||||||
import { initClientVersionChecks } from '../lib/client-version';
|
|
||||||
import { saveWechatLoginResult } from '../lib/pay-wechat';
|
import { saveWechatLoginResult } from '../lib/pay-wechat';
|
||||||
import { applyWechatShare } from '../lib/wechat-share';
|
import { applyWechatShare } from '../lib/wechat-share';
|
||||||
import { handleWechatAuthCallback } from '../lib/wechat-auth';
|
import { handleWechatAuthCallback } from '../lib/wechat-auth';
|
||||||
@@ -39,7 +38,6 @@ export default function WechatShareBootstrap() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void capturePromoSceneAndTouchScan();
|
void capturePromoSceneAndTouchScan();
|
||||||
initClientVersionChecks();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import Taro from '@tarojs/taro';
|
|
||||||
import { fetchClientConfig } from './pay-wechat';
|
|
||||||
|
|
||||||
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
|
||||||
export const APP_VERSION = '3.4.15';
|
|
||||||
|
|
||||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
|
||||||
|
|
||||||
function parseSemver(v: string): number[] {
|
|
||||||
return v.split('.').map((n) => parseInt(n, 10) || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compareSemver(a: string, b: string): number {
|
|
||||||
const pa = parseSemver(a);
|
|
||||||
const pb = parseSemver(b);
|
|
||||||
const len = Math.max(pa.length, pb.length);
|
|
||||||
for (let i = 0; i < len; i += 1) {
|
|
||||||
const da = pa[i] ?? 0;
|
|
||||||
const db = pb[i] ?? 0;
|
|
||||||
if (da !== db) return da - db;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function exitMiniProgramIfSupported() {
|
|
||||||
if (process.env.TARO_ENV !== 'weapp') return;
|
|
||||||
if (typeof Taro.exitMiniProgram !== 'function') return;
|
|
||||||
void Taro.exitMiniProgram({});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 微信 CDN 有新包时引导 applyUpdate;返回是否已触发更新流程 */
|
|
||||||
function tryWeappForceUpdate(min: string): Promise<boolean> {
|
|
||||||
if (process.env.TARO_ENV !== 'weapp' || typeof Taro.getUpdateManager !== 'function') {
|
|
||||||
return Promise.resolve(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const manager = Taro.getUpdateManager();
|
|
||||||
let settled = false;
|
|
||||||
const finish = (updated: boolean) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
resolve(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
manager.onCheckForUpdate((res) => {
|
|
||||||
if (!res.hasUpdate) {
|
|
||||||
finish(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
manager.onUpdateReady(() => {
|
|
||||||
void Taro.showModal({
|
|
||||||
title: '版本过低',
|
|
||||||
content: `当前 ${APP_VERSION_LABEL} 低于要求版本 v${min},已检测到新版本,请立即更新后继续使用。`,
|
|
||||||
showCancel: false,
|
|
||||||
confirmText: '立即更新',
|
|
||||||
success: (r) => {
|
|
||||||
if (r.confirm) {
|
|
||||||
manager.applyUpdate();
|
|
||||||
finish(true);
|
|
||||||
} else {
|
|
||||||
finish(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fail: () => finish(false),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
manager.onUpdateFailed(() => finish(false));
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => finish(false), 4000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enforceMinClientVersion(min: string) {
|
|
||||||
const updated = await tryWeappForceUpdate(min);
|
|
||||||
if (updated) return;
|
|
||||||
|
|
||||||
await Taro.showModal({
|
|
||||||
title: '版本过低',
|
|
||||||
content: `当前 ${APP_VERSION_LABEL} 低于要求版本 v${min},请更新至最新版本后继续使用。`,
|
|
||||||
showCancel: false,
|
|
||||||
confirmText: '我知道了',
|
|
||||||
});
|
|
||||||
|
|
||||||
exitMiniProgramIfSupported();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkClientVersionGate() {
|
|
||||||
try {
|
|
||||||
const config = await fetchClientConfig();
|
|
||||||
const min = config.minClientVersion?.trim();
|
|
||||||
if (min && compareSemver(APP_VERSION, min) < 0) {
|
|
||||||
await enforceMinClientVersion(min);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* 配置拉取失败不阻塞启动 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setupWeappUpdateManager() {
|
|
||||||
if (process.env.TARO_ENV !== 'weapp') return;
|
|
||||||
if (typeof Taro.getUpdateManager !== 'function') return;
|
|
||||||
|
|
||||||
const manager = Taro.getUpdateManager();
|
|
||||||
manager.onCheckForUpdate((res) => {
|
|
||||||
if (!res.hasUpdate) return;
|
|
||||||
manager.onUpdateReady(() => {
|
|
||||||
void Taro.showModal({
|
|
||||||
title: '更新提示',
|
|
||||||
content: '新版本已准备好,是否重启应用?',
|
|
||||||
confirmText: '立即更新',
|
|
||||||
success: (r) => {
|
|
||||||
if (r.confirm) manager.applyUpdate();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
manager.onUpdateFailed(() => {
|
|
||||||
void Taro.showModal({
|
|
||||||
title: '更新失败',
|
|
||||||
content: '新版本下载失败,请删除小程序后重新搜索打开。',
|
|
||||||
showCancel: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initClientVersionChecks() {
|
|
||||||
setupWeappUpdateManager();
|
|
||||||
void checkClientVersionGate();
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import Taro from '@tarojs/taro';
|
|
||||||
|
|
||||||
const MAX_BYTES = 10 * 1024 * 1024;
|
|
||||||
|
|
||||||
/** 小程序临时文件超过 10MB 时用 compressImage 压缩后再上传 */
|
|
||||||
export async function compressWeappImageIfNeeded(tempFilePath: string): Promise<string> {
|
|
||||||
if (process.env.TARO_ENV !== 'weapp') return tempFilePath;
|
|
||||||
|
|
||||||
let path = tempFilePath;
|
|
||||||
let size = await getFileSize(path);
|
|
||||||
if (size <= MAX_BYTES) return path;
|
|
||||||
|
|
||||||
const qualities = [80, 65, 50, 40];
|
|
||||||
for (const quality of qualities) {
|
|
||||||
const res = await Taro.compressImage({ src: path, quality });
|
|
||||||
path = res.tempFilePath;
|
|
||||||
size = await getFileSize(path);
|
|
||||||
if (size <= MAX_BYTES) return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (size > MAX_BYTES) {
|
|
||||||
throw new Error('图片压缩后仍超过 10MB,请换一张较小的图片');
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getFileSize(filePath: string): Promise<number> {
|
|
||||||
try {
|
|
||||||
const info = await Taro.getFileInfo({ filePath });
|
|
||||||
return typeof info.size === 'number' ? info.size : 0;
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -69,15 +69,12 @@ export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
|
|||||||
/** 上传 chooseAvatar 临时文件到 OSS,并返回已登记到当前用户的真实资源。 */
|
/** 上传 chooseAvatar 临时文件到 OSS,并返回已登记到当前用户的真实资源。 */
|
||||||
export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> {
|
export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> {
|
||||||
const { API_BASE, CLIENT_APP, getToken } = await import('./api');
|
const { API_BASE, CLIENT_APP, getToken } = await import('./api');
|
||||||
const { compressWeappImageIfNeeded } = await import('./compress-image');
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) throw new Error('请先登录');
|
if (!token) throw new Error('请先登录');
|
||||||
|
|
||||||
const filePath = await compressWeappImageIfNeeded(tempFilePath);
|
|
||||||
|
|
||||||
const res = await Taro.uploadFile({
|
const res = await Taro.uploadFile({
|
||||||
url: `${API_BASE}/common/resources/upload`,
|
url: `${API_BASE}/common/resources/upload`,
|
||||||
filePath,
|
filePath: tempFilePath,
|
||||||
name: 'file',
|
name: 'file',
|
||||||
formData: {
|
formData: {
|
||||||
bizType: 'AVATAR',
|
bizType: 'AVATAR',
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import type { OrderTrackDto } from '@dukang/shared-types';
|
|
||||||
import { isXfxProviderCode } from '@dukang/shared-types';
|
|
||||||
import { formatShanghaiDateTime } from './datetime';
|
|
||||||
|
|
||||||
export type OrderDelivery = {
|
|
||||||
provider?: string;
|
|
||||||
trackingNo?: string;
|
|
||||||
logisticsCompany?: string;
|
|
||||||
manualQueryUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OrderTrackNode = {
|
|
||||||
trackInfo?: string;
|
|
||||||
createTime?: string;
|
|
||||||
createdAt?: string;
|
|
||||||
statusName?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OrderTrackEstimatedArrival = {
|
|
||||||
arriveTime: string;
|
|
||||||
siteName?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OrderTrackResponse = OrderTrackDto;
|
|
||||||
|
|
||||||
/** 订单详情页展示物流摘要的状态(配送中) */
|
|
||||||
export const ORDER_LOGISTICS_PREVIEW_STATUSES = ['SHIPPING', 'SHIPPED'] as const;
|
|
||||||
|
|
||||||
const PHONE_PATTERN = /(1[3-9]\d{9}|\d{3,4}-\d{7,8})/g;
|
|
||||||
|
|
||||||
function compareTrackNodeTime(a: OrderTrackNode, b: OrderTrackNode) {
|
|
||||||
const ta = new Date(a.createTime || a.createdAt || 0).getTime();
|
|
||||||
const tb = new Date(b.createTime || b.createdAt || 0).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 function deliveryProviderLabel(provider?: string, company?: string) {
|
|
||||||
if (company) return company;
|
|
||||||
if (!provider || provider === 'MOCK' || isXfxProviderCode(provider)) {
|
|
||||||
return '小飞侠配送';
|
|
||||||
}
|
|
||||||
if (provider === 'LOGISTICS') return '快递配送';
|
|
||||||
return provider;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldLoadOrderTrack(delivery?: OrderDelivery | null) {
|
|
||||||
if (!delivery) return false;
|
|
||||||
if (delivery.trackingNo) return true;
|
|
||||||
return isXfxProviderCode(delivery.provider || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTrackNodeTime(node: OrderTrackNode) {
|
|
||||||
return formatShanghaiDateTime(node.createTime || node.createdAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sortTrackNodesNewestFirst(nodes: OrderTrackNode[]): OrderTrackNode[] {
|
|
||||||
return [...nodes].sort((a, b) => -compareTrackNodeTime(a, b));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sortTrackNodesOldestFirst(nodes: OrderTrackNode[]): OrderTrackNode[] {
|
|
||||||
return [...nodes].sort(compareTrackNodeTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function latestTrackNode(nodes: OrderTrackNode[]) {
|
|
||||||
if (!nodes.length) return null;
|
|
||||||
let latest = nodes[0];
|
|
||||||
for (let i = 1; i < nodes.length; i += 1) {
|
|
||||||
if (compareTrackNodeTime(nodes[i], latest) > 0) {
|
|
||||||
latest = nodes[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return latest;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLogisticsNotArrived(nodes: OrderTrackNode[], orderStatus?: string) {
|
|
||||||
if (['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(orderStatus || '')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (nodes.length === 0) return true;
|
|
||||||
return !nodes.some(
|
|
||||||
(node) => node.statusName?.includes('签收') || node.trackInfo?.includes('签收'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatEstimatedArrival(eta?: OrderTrackEstimatedArrival | null) {
|
|
||||||
if (!eta?.arriveTime) return '';
|
|
||||||
const time = formatShanghaiDateTime(eta.arriveTime);
|
|
||||||
if (eta.siteName) return `预计送达:${time}(${eta.siteName})`;
|
|
||||||
return `预计送达:${time}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LogisticsTextSegment = { type: 'text' | 'phone'; value: string };
|
|
||||||
|
|
||||||
export function splitLogisticsText(text: string): LogisticsTextSegment[] {
|
|
||||||
if (!text) return [];
|
|
||||||
const segments: LogisticsTextSegment[] = [];
|
|
||||||
let lastIndex = 0;
|
|
||||||
const regex = new RegExp(PHONE_PATTERN.source, 'g');
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
while ((match = regex.exec(text)) !== null) {
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
segments.push({ type: 'text', value: text.slice(lastIndex, match.index) });
|
|
||||||
}
|
|
||||||
segments.push({ type: 'phone', value: match[0] });
|
|
||||||
lastIndex = match.index + match[0].length;
|
|
||||||
}
|
|
||||||
if (lastIndex < text.length) {
|
|
||||||
segments.push({ type: 'text', value: text.slice(lastIndex) });
|
|
||||||
}
|
|
||||||
return segments.length ? segments : [{ type: 'text', value: text }];
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export * from './order-logistics-utils';
|
|
||||||
import type { OrderTrackResponse } from './order-logistics-utils';
|
|
||||||
import { request } from './api';
|
|
||||||
|
|
||||||
export async function fetchOrderTrack(orderId: string) {
|
|
||||||
return request<OrderTrackResponse>(`/trade/orders/${orderId}/track`);
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
@@ -26,13 +23,11 @@ import {
|
|||||||
} from '../../lib/mini-wechat-profile';
|
} from '../../lib/mini-wechat-profile';
|
||||||
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
|
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
|
||||||
import { isWechatEnv } from '../../lib/weixin';
|
import { isWechatEnv } from '../../lib/weixin';
|
||||||
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 +69,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 +140,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 +303,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 +316,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>
|
||||||
@@ -514,7 +499,7 @@ export default function MinePage() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="mine-footer">
|
<View className="mine-footer">
|
||||||
<Text className="mine-version">杜康好客 {APP_VERSION_LABEL}</Text>
|
<Text className="mine-version">杜康好客</Text>
|
||||||
<Text className="mine-logout" onClick={() => logout()}>
|
<Text className="mine-logout" onClick={() => logout()}>
|
||||||
退出登录
|
退出登录
|
||||||
</Text>
|
</Text>
|
||||||
@@ -524,13 +509,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 +519,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 +578,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>
|
||||||
|
|||||||
@@ -141,14 +141,6 @@ export default function OrderConfirmPickupPage() {
|
|||||||
const ready = await ensurePayReady(returnPath);
|
const ready = await ensurePayReady(returnPath);
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
|
|
||||||
const { confirm } = await Taro.showModal({
|
|
||||||
title: '确认提交订单',
|
|
||||||
content: `请确保您已拿到货品,货款将直接打给商家,如不是现场交易请选择立即购买方式下单,我们会为您安排配送到家。`,
|
|
||||||
confirmText: '确认提交',
|
|
||||||
cancelText: '再想想',
|
|
||||||
});
|
|
||||||
if (!confirm) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,25 +6,14 @@ import SubPageHeader from '../../components/SubPageHeader';
|
|||||||
import ShareNavButton from '../../components/ShareNavButton';
|
import ShareNavButton from '../../components/ShareNavButton';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import ContactCsButton from '../../components/ContactCsButton';
|
import ContactCsButton from '../../components/ContactCsButton';
|
||||||
import LogisticsRichText from '../../components/LogisticsRichText';
|
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||||
import {
|
|
||||||
formatEstimatedArrival,
|
|
||||||
isLogisticsNotArrived,
|
|
||||||
latestTrackNode,
|
|
||||||
ORDER_LOGISTICS_PREVIEW_STATUSES,
|
|
||||||
shouldLoadOrderTrack,
|
|
||||||
type OrderTrackEstimatedArrival,
|
|
||||||
type OrderTrackNode,
|
|
||||||
} from '../../lib/order-logistics-utils';
|
|
||||||
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';
|
||||||
|
|
||||||
@@ -55,15 +44,8 @@ type OrderDetail = {
|
|||||||
orderType?: string;
|
orderType?: string;
|
||||||
isProxyOrder?: boolean;
|
isProxyOrder?: boolean;
|
||||||
proxyPartnerName?: string | null;
|
proxyPartnerName?: string | null;
|
||||||
deliveryType?: string;
|
|
||||||
items?: OrderItem[];
|
items?: OrderItem[];
|
||||||
wechatConfirm?: WechatConfirmPayload | null;
|
wechatConfirm?: WechatConfirmPayload | null;
|
||||||
delivery?: {
|
|
||||||
provider?: string;
|
|
||||||
trackingNo?: string;
|
|
||||||
logisticsCompany?: string;
|
|
||||||
manualQueryUrl?: string;
|
|
||||||
} | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
@@ -96,41 +78,11 @@ export default function OrderDetailPage() {
|
|||||||
usePageView('order_detail_view', orderId ? { orderId } : undefined);
|
usePageView('order_detail_view', orderId ? { orderId } : undefined);
|
||||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const [latestTrack, setLatestTrack] = useState<OrderTrackNode | null>(null);
|
|
||||||
const [estimatedArrival, setEstimatedArrival] = useState<OrderTrackEstimatedArrival | null>(null);
|
|
||||||
const [trackLoading, setTrackLoading] = useState(false);
|
|
||||||
|
|
||||||
async function loadOrderTrack(delivery: OrderDetail['delivery'], orderStatus?: string) {
|
|
||||||
if (!orderId || !shouldLoadOrderTrack(delivery)) {
|
|
||||||
setLatestTrack(null);
|
|
||||||
setEstimatedArrival(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTrackLoading(true);
|
|
||||||
try {
|
|
||||||
const track = await fetchOrderTrack(orderId);
|
|
||||||
const nodes = track.nodes ?? [];
|
|
||||||
setLatestTrack(latestTrackNode(nodes));
|
|
||||||
setEstimatedArrival(
|
|
||||||
isLogisticsNotArrived(nodes, orderStatus) && track.estimatedArrival
|
|
||||||
? track.estimatedArrival
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
setLatestTrack(null);
|
|
||||||
setEstimatedArrival(null);
|
|
||||||
} finally {
|
|
||||||
setTrackLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!orderId) return;
|
if (!orderId) return;
|
||||||
request<OrderDetail>(`/trade/orders/${orderId}`)
|
request<OrderDetail>(`/trade/orders/${orderId}`)
|
||||||
.then((data) => {
|
.then(setOrder)
|
||||||
setOrder(data);
|
|
||||||
void loadOrderTrack(data.delivery, data.status);
|
|
||||||
})
|
|
||||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||||
}, [orderId]);
|
}, [orderId]);
|
||||||
|
|
||||||
@@ -138,10 +90,7 @@ export default function OrderDetailPage() {
|
|||||||
if (!orderId) return;
|
if (!orderId) return;
|
||||||
// 从微信确认收货组件返回后刷新
|
// 从微信确认收货组件返回后刷新
|
||||||
request<OrderDetail>(`/trade/orders/${orderId}`)
|
request<OrderDetail>(`/trade/orders/${orderId}`)
|
||||||
.then((data) => {
|
.then(setOrder)
|
||||||
setOrder(data);
|
|
||||||
void loadOrderTrack(data.delivery, data.status);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,18 +99,6 @@ export default function OrderDetailPage() {
|
|||||||
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
|
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
|
||||||
const canConfirmReceive =
|
const canConfirmReceive =
|
||||||
!!order && !isReship && !isProxy && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
|
!!order && !isReship && !isProxy && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
|
||||||
const canViewLogistics =
|
|
||||||
!!order &&
|
|
||||||
order.deliveryType !== 'ON_SITE_PICKUP' &&
|
|
||||||
!isReship &&
|
|
||||||
['PENDING_SHIP', 'OUT_WAREHOUSE', 'SHIPPING', 'SHIPPED', 'PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
|
|
||||||
order.status || '',
|
|
||||||
);
|
|
||||||
const showLogisticsPreview =
|
|
||||||
canViewLogistics &&
|
|
||||||
ORDER_LOGISTICS_PREVIEW_STATUSES.includes(
|
|
||||||
(order?.status || '') as (typeof ORDER_LOGISTICS_PREVIEW_STATUSES)[number],
|
|
||||||
);
|
|
||||||
|
|
||||||
const item = order?.items?.[0];
|
const item = order?.items?.[0];
|
||||||
const productName = item?.productName || order?.productName || '杜康商品';
|
const productName = item?.productName || order?.productName || '杜康商品';
|
||||||
@@ -174,20 +111,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;
|
||||||
@@ -198,11 +134,6 @@ export default function OrderDetailPage() {
|
|||||||
Taro.navigateTo({ url: '/pages/customer-service/index' });
|
Taro.navigateTo({ url: '/pages/customer-service/index' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function goLogistics() {
|
|
||||||
if (!order) return;
|
|
||||||
Taro.navigateTo({ url: `/pages/order-logistics/index?id=${order.id}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmReceive() {
|
async function confirmReceive() {
|
||||||
if (!order || !canConfirmReceive || confirming) return;
|
if (!order || !canConfirmReceive || confirming) return;
|
||||||
|
|
||||||
@@ -280,34 +211,6 @@ export default function OrderDetailPage() {
|
|||||||
{isProxy && order.proxyPartnerName ? (
|
{isProxy && order.proxyPartnerName ? (
|
||||||
<Text className="order-proxy-hint">由合伙人 {order.proxyPartnerName} 代下</Text>
|
<Text className="order-proxy-hint">由合伙人 {order.proxyPartnerName} 代下</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{showLogisticsPreview ? (
|
|
||||||
<View className="order-logistics-preview-block">
|
|
||||||
<View className="order-logistics-preview" onClick={goLogistics}>
|
|
||||||
<View className="order-logistics-preview-main">
|
|
||||||
{trackLoading ? (
|
|
||||||
<Text className="order-logistics-preview-info u-muted">物流信息加载中…</Text>
|
|
||||||
) : latestTrack?.trackInfo ? (
|
|
||||||
<LogisticsRichText
|
|
||||||
text={latestTrack.trackInfo}
|
|
||||||
className="order-logistics-preview-info"
|
|
||||||
/>
|
|
||||||
) : order.delivery?.manualQueryUrl ? (
|
|
||||||
<Text className="order-logistics-preview-info u-muted">物流已更新,点击查看详情</Text>
|
|
||||||
) : (
|
|
||||||
<Text className="order-logistics-preview-info u-muted">物流信息待更新,请稍后查看</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Text className="order-logistics-preview-link">物流详情</Text>
|
|
||||||
</View>
|
|
||||||
{estimatedArrival ? (
|
|
||||||
<Text className="order-logistics-eta">{formatEstimatedArrival(estimatedArrival)}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
) : canViewLogistics ? (
|
|
||||||
<Text className="order-logistics-link" onClick={goLogistics}>
|
|
||||||
查看物流追踪
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
<View className="order-card">
|
<View className="order-card">
|
||||||
<Text className="order-card-title">商品信息</Text>
|
<Text className="order-card-title">商品信息</Text>
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export default definePageConfig({
|
|
||||||
navigationStyle: 'custom',
|
|
||||||
navigationBarTitleText: '物流详情',
|
|
||||||
});
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
|
||||||
import PageShell from '../../components/PageShell';
|
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
|
||||||
import LogisticsRichText from '../../components/LogisticsRichText';
|
|
||||||
import { request, toast } from '../../lib/api';
|
|
||||||
import {
|
|
||||||
deliveryProviderLabel,
|
|
||||||
formatTrackNodeTime,
|
|
||||||
shouldLoadOrderTrack,
|
|
||||||
sortTrackNodesOldestFirst,
|
|
||||||
type OrderDelivery,
|
|
||||||
type OrderTrackNode,
|
|
||||||
} from '../../lib/order-logistics-utils';
|
|
||||||
import { fetchOrderTrack } from '../../lib/order-logistics';
|
|
||||||
import { usePageView } from '../../lib/usePageView';
|
|
||||||
|
|
||||||
type OrderDetail = {
|
|
||||||
id: string;
|
|
||||||
orderNo?: string;
|
|
||||||
status?: string;
|
|
||||||
delivery?: OrderDelivery | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function OrderLogisticsPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const orderId = router.params.id ?? '';
|
|
||||||
usePageView('order_logistics_view', orderId ? { orderId } : undefined);
|
|
||||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
|
||||||
const [trackNodes, setTrackNodes] = useState<OrderTrackNode[]>([]);
|
|
||||||
const [signPhotoUrls, setSignPhotoUrls] = useState<string[]>([]);
|
|
||||||
const [trackLoading, setTrackLoading] = useState(false);
|
|
||||||
|
|
||||||
const sortedTrackNodes = useMemo(
|
|
||||||
() => (Array.isArray(trackNodes) ? sortTrackNodesOldestFirst(trackNodes) : []),
|
|
||||||
[trackNodes],
|
|
||||||
);
|
|
||||||
const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
|
|
||||||
order?.status || '',
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!orderId) return;
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const data = await request<OrderDetail>(`/trade/orders/${orderId}`);
|
|
||||||
if (cancelled) return;
|
|
||||||
setOrder(data);
|
|
||||||
if (!shouldLoadOrderTrack(data.delivery)) {
|
|
||||||
setTrackNodes([]);
|
|
||||||
setSignPhotoUrls([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTrackLoading(true);
|
|
||||||
try {
|
|
||||||
const track = await fetchOrderTrack(orderId);
|
|
||||||
if (!cancelled) {
|
|
||||||
setTrackNodes(track.nodes ?? []);
|
|
||||||
setSignPhotoUrls(track.signPhotoUrls ?? []);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) {
|
|
||||||
setTrackNodes([]);
|
|
||||||
setSignPhotoUrls([]);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setTrackLoading(false);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (!cancelled) toast(e instanceof Error ? e.message : '加载失败');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [orderId]);
|
|
||||||
|
|
||||||
function openManualQuery() {
|
|
||||||
const url = order?.delivery?.manualQueryUrl?.trim();
|
|
||||||
if (!url) {
|
|
||||||
toast('暂无物流查询链接');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
|
|
||||||
window.open(url, '_blank');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Taro.setClipboardData({ data: url })
|
|
||||||
.then(() => toast('查询链接已复制,请在浏览器中打开'))
|
|
||||||
.catch(() => toast('无法打开物流查询'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function previewSignPhotos(index: number) {
|
|
||||||
if (signPhotoUrls.length === 0) return;
|
|
||||||
Taro.previewImage({ current: signPhotoUrls[index], urls: signPhotoUrls }).catch(() =>
|
|
||||||
toast('无法预览图片'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const delivery = order?.delivery;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell variant="sub" className="order-logistics-page">
|
|
||||||
<SubPageHeader title="物流详情" />
|
|
||||||
<View className="sub-page-body order-logistics-body">
|
|
||||||
{!order ? (
|
|
||||||
<View className="u-empty">加载中…</View>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<View className="order-card order-logistics-info-card">
|
|
||||||
<Text className="order-card-title">物流信息</Text>
|
|
||||||
<View className="order-row">
|
|
||||||
<Text className="order-row-label">配送方式</Text>
|
|
||||||
<Text className="order-row-value">
|
|
||||||
{deliveryProviderLabel(delivery?.provider, delivery?.logisticsCompany)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{delivery?.trackingNo ? (
|
|
||||||
<View className="order-row">
|
|
||||||
<Text className="order-row-label">运单号</Text>
|
|
||||||
<Text className="order-row-value">{delivery.trackingNo}</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{delivery?.manualQueryUrl ? (
|
|
||||||
<View className="order-row">
|
|
||||||
<Text className="order-row-label">物流查询</Text>
|
|
||||||
<Text className="order-row-value order-row-value--link" onClick={openManualQuery}>
|
|
||||||
查看物流
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{!delivery?.trackingNo && !delivery?.manualQueryUrl ? (
|
|
||||||
<Text className="u-muted">物流信息待更新,请稍后查看</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{signPhotoUrls.length > 0 ? (
|
|
||||||
<View className="order-card">
|
|
||||||
<Text className="order-card-title">签收照片</Text>
|
|
||||||
<View className="order-logistics-sign-photos">
|
|
||||||
{signPhotoUrls.map((url, index) => (
|
|
||||||
<Image
|
|
||||||
key={url}
|
|
||||||
className="order-logistics-sign-photo"
|
|
||||||
src={url}
|
|
||||||
mode="aspectFill"
|
|
||||||
onClick={() => previewSignPhotos(index)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{trackLoading ? (
|
|
||||||
<View className="order-card">
|
|
||||||
<Text className="order-card-title">物流动态</Text>
|
|
||||||
<Text className="u-muted">加载中…</Text>
|
|
||||||
</View>
|
|
||||||
) : sortedTrackNodes.length > 0 ? (
|
|
||||||
<View className="order-card">
|
|
||||||
<Text className="order-card-title">物流动态</Text>
|
|
||||||
<View
|
|
||||||
className={`order-logistics-timeline${
|
|
||||||
timelineComplete ? ' order-logistics-timeline--complete' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{sortedTrackNodes.map((node, index) => {
|
|
||||||
const isLatest = index === sortedTrackNodes.length - 1;
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={`${node.trackInfo}-${node.createTime}-${index}`}
|
|
||||||
className={[
|
|
||||||
'order-logistics-timeline-item',
|
|
||||||
'order-logistics-timeline-item--done',
|
|
||||||
isLatest ? 'order-logistics-timeline-item--latest' : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
>
|
|
||||||
<View className="order-logistics-timeline-dot" />
|
|
||||||
<View className="order-logistics-timeline-body">
|
|
||||||
{node.statusName ? (
|
|
||||||
<Text className="order-logistics-timeline-status">{node.statusName}</Text>
|
|
||||||
) : null}
|
|
||||||
<LogisticsRichText
|
|
||||||
text={node.trackInfo || '—'}
|
|
||||||
className="order-logistics-node-info"
|
|
||||||
/>
|
|
||||||
<Text className="order-logistics-node-time">{formatTrackNodeTime(node)}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : shouldLoadOrderTrack(delivery) ? (
|
|
||||||
<View className="order-card">
|
|
||||||
<Text className="order-card-title">物流动态</Text>
|
|
||||||
<Text className="u-muted">暂无路由信息,请稍后刷新</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</PageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@ import type { ProductDetailContentDto } from '@dukang/shared-types';
|
|||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import PageNavBar from '../../components/PageNavBar';
|
import PageNavBar from '../../components/PageNavBar';
|
||||||
import ProductCarousel from '../../components/ProductCarousel';
|
import ProductCarousel from '../../components/ProductCarousel';
|
||||||
|
import ShareNavButton from '../../components/ShareNavButton';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import { goLogin } from '../../lib/auth-nav';
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
import { ensurePayReady } from '../../lib/pay-ready';
|
import { ensurePayReady } from '../../lib/pay-ready';
|
||||||
@@ -27,9 +28,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 +89,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();
|
||||||
@@ -165,11 +167,12 @@ export default function ProductDetailPage() {
|
|||||||
solid={headerSolid}
|
solid={headerSolid}
|
||||||
titleVisible={headerSolid}
|
titleVisible={headerSolid}
|
||||||
onBack={goBack}
|
onBack={goBack}
|
||||||
|
right={<ShareNavButton payload={sharePayload} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View className="product-detail-main">
|
<View className="product-detail-main">
|
||||||
<View className="product-detail-hero full-bleed">
|
<View className="product-detail-hero full-bleed">
|
||||||
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" previewable />
|
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="product-detail-info">
|
<View className="product-detail-info">
|
||||||
|
|||||||
@@ -15,13 +15,11 @@ 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 } from '../../lib/phone';
|
|
||||||
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 = {
|
||||||
@@ -237,15 +235,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),
|
||||||
@@ -253,9 +254,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();
|
||||||
@@ -268,7 +271,6 @@ export default function StoreDetailPage() {
|
|||||||
toast('暂无门店电话');
|
toast('暂无门店电话');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
track('store_phone_call', { storeId: store.id });
|
|
||||||
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
|
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,19 +317,12 @@ 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 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({
|
||||||
@@ -348,13 +343,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">
|
||||||
@@ -385,9 +374,7 @@ export default function StoreDetailPage() {
|
|||||||
|
|
||||||
{store.phone ? (
|
{store.phone ? (
|
||||||
<View className="store-detail-row">
|
<View className="store-detail-row">
|
||||||
<Text className="store-detail-meta store-detail-meta--flex">
|
<Text className="store-detail-meta store-detail-meta--flex">电话: {store.phone}</Text>
|
||||||
电话: {maskPhone(store.phone)}
|
|
||||||
</Text>
|
|
||||||
<Text className="store-detail-action" onClick={callStore}>
|
<Text className="store-detail-action" onClick={callStore}>
|
||||||
拨打
|
拨打
|
||||||
</Text>
|
</Text>
|
||||||
@@ -409,21 +396,27 @@ export default function StoreDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{packages.length > 0 ? (
|
{store.packages && store.packages.length > 0 ? (
|
||||||
<View className="store-detail-section store-detail-section--packages">
|
<View className="store-detail-section">
|
||||||
<Text className="store-detail-section-title">门店套餐</Text>
|
<Text className="store-detail-section-title">门店套餐</Text>
|
||||||
<View className="store-detail-package-list">
|
{store.packages.map((pkg, index) => (
|
||||||
{packages.map((pkg, index) => (
|
<View key={`${pkg.name}-${index}`} className="store-detail-package-card">
|
||||||
<View
|
{pkg.imageUrl ? (
|
||||||
key={`${pkg.name}-${index}`}
|
<Image className="store-detail-package-img" src={pkg.imageUrl} mode="aspectFill" />
|
||||||
className="store-detail-package-list-item"
|
) : null}
|
||||||
onClick={() => openPackageDetail(index)}
|
<Text className="store-detail-package-name">{pkg.name}</Text>
|
||||||
>
|
<Text className="store-detail-package-body">
|
||||||
<Text className="store-detail-package-list-title">{pkg.name}</Text>
|
{formatRedeemAmountYuan(pkg.price)} 元 · {pkg.dishes}
|
||||||
|
</Text>
|
||||||
|
{pkg.usableTime ? (
|
||||||
|
<Text className="store-detail-package-meta">使用时间:{pkg.usableTime}</Text>
|
||||||
|
) : null}
|
||||||
|
{pkg.otherNotes ? (
|
||||||
|
<Text className="store-detail-package-meta">说明:{pkg.otherNotes}</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{intro ? (
|
{intro ? (
|
||||||
|
|||||||
@@ -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,34 @@ export default function StoresPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hoursText(store: Store): string {
|
function formatHours(store: Store) {
|
||||||
const parts: string[] = [];
|
// 列表只展示第一段营业时间,避免挤占一行
|
||||||
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
if (store.openTime && store.closeTime) {
|
||||||
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
return `营业时间: ${store.openTime}-${store.closeTime}`;
|
||||||
if (!parts.length) parts.push('10:00-22:00');
|
}
|
||||||
return `营业时间: ${parts.join(' ')}`;
|
return '营业时间: 10:00-22:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +432,44 @@ 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" numberOfLines={1}>
|
||||||
|
{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;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user