第三次版本迭代更新
This commit is contained in:
60
H5页面规划.md
60
H5页面规划.md
@@ -1,4 +1,4 @@
|
|||||||
# 酒店员工端 H5 页面规划
|
# 员工端 H5 页面规划
|
||||||
|
|
||||||
## 产品定位
|
## 产品定位
|
||||||
|
|
||||||
@@ -52,9 +52,9 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 工作台 | `/home` | 展示关键数据和快捷入口 |
|
| 工作台 | `/home` | 展示关键数据和快捷入口 |
|
||||||
| 订单 | `/orders` | 查询订单、进入详情 |
|
| 订单 | `/orders` | 查询订单、进入详情 |
|
||||||
| 核销 | `/verify` | 扫码或输入订单号核销 |
|
| 核销 | `/verify` | 手机摄像头扫码核销 |
|
||||||
| 事件 | `/events` | 查看事件列表 |
|
| 事件 | `/events` | 查看事件列表 |
|
||||||
| 我的 | `/mine` | 当前账号、接口模式、退出登录 |
|
| 我的 | `/mine` | 当前账号、登录状态、退出登录 |
|
||||||
|
|
||||||
## 核心流程
|
## 核心流程
|
||||||
|
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
-> 输入验证码
|
-> 输入验证码
|
||||||
-> 调用 OAuth2 手机号登录
|
-> 调用 OAuth2 手机号登录
|
||||||
-> 保存 access_token
|
-> 保存 access_token
|
||||||
|
-> 调用组织关系接口
|
||||||
-> 进入工作台
|
-> 进入工作台
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -96,11 +97,12 @@
|
|||||||
-> 核销结果
|
-> 核销结果
|
||||||
```
|
```
|
||||||
|
|
||||||
也支持:
|
扫码入口支持:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
核销入口
|
核销入口
|
||||||
-> 输入订单号
|
-> 点击进行扫码
|
||||||
|
-> 解析二维码内容
|
||||||
-> 查询订单详情
|
-> 查询订单详情
|
||||||
-> 核销确认
|
-> 核销确认
|
||||||
-> 核销结果
|
-> 核销结果
|
||||||
@@ -114,7 +116,9 @@
|
|||||||
| 订单详情 | `POST /hotelStaff/order/userOrderDetail` |
|
| 订单详情 | `POST /hotelStaff/order/userOrderDetail` |
|
||||||
| 确认核销 | `POST /hotelStaff/order/writeOff` |
|
| 确认核销 | `POST /hotelStaff/order/writeOff` |
|
||||||
|
|
||||||
当前后端核销能力以 `orderId` 为核心,暂未看到独立“核销码查询”接口。第一版扫码可先约定二维码内容为订单号;如果业务需要独立券码,后端建议补充“按核销码查询订单/商品”的接口。
|
当前二维码内容规则是 `orderId&packageName`,其中 `packageName` 是套餐名称。前端也兼容只有 `orderId` 的二维码内容。
|
||||||
|
|
||||||
|
扫码内容带 `packageName` 时,核销确认页只能核销该套餐商品;扫码内容只有 `orderId` 时,核销确认页按订单详情展示可选套餐。
|
||||||
|
|
||||||
### 事件流程
|
### 事件流程
|
||||||
|
|
||||||
@@ -164,7 +168,7 @@
|
|||||||
- 当前日期
|
- 当前日期
|
||||||
- 当前账号
|
- 当前账号
|
||||||
- 待使用订单数
|
- 待使用订单数
|
||||||
- 今日核销数
|
- 待确认订单数
|
||||||
- 生效事件数
|
- 生效事件数
|
||||||
- 今日订单数
|
- 今日订单数
|
||||||
- 快捷入口:扫码核销、订单列表、发布事件、事件列表
|
- 快捷入口:扫码核销、订单列表、发布事件、事件列表
|
||||||
@@ -184,9 +188,8 @@
|
|||||||
|
|
||||||
展示内容:
|
展示内容:
|
||||||
|
|
||||||
- 搜索框:订单号/手机号
|
- 搜索框:手机号
|
||||||
- 状态筛选:全部、待使用、待确认、退款中、已完成
|
- 状态筛选:全部、待使用、待确认、退款中、已完成
|
||||||
- 商品类型筛选:全部、酒店、门票、餐饮
|
|
||||||
- 订单卡片:订单号、状态、商品名、联系人、手机号、金额、数量、时间
|
- 订单卡片:订单号、状态、商品名、联系人、手机号、金额、数量、时间
|
||||||
|
|
||||||
主要操作:
|
主要操作:
|
||||||
@@ -228,18 +231,18 @@
|
|||||||
|
|
||||||
展示内容:
|
展示内容:
|
||||||
|
|
||||||
- 扫码核销入口
|
- 点击进行扫码
|
||||||
- 手动输入订单号
|
|
||||||
|
|
||||||
主要操作:
|
主要操作:
|
||||||
|
|
||||||
- 输入订单号查询订单
|
- 调起手机摄像头扫码
|
||||||
|
- 解析 `orderId&packageName` 或 `orderId`
|
||||||
- 进入核销确认
|
- 进入核销确认
|
||||||
|
|
||||||
备注:
|
备注:
|
||||||
|
|
||||||
- 当前扫码入口已预留。
|
- 当前不提供手动输入订单号兜底。
|
||||||
- 第一版先按订单号核销。
|
- 摄像头扫码要求浏览器支持摄像头能力,生产环境建议使用 HTTPS。
|
||||||
|
|
||||||
### `/verify/confirm` 核销确认
|
### `/verify/confirm` 核销确认
|
||||||
|
|
||||||
@@ -252,7 +255,9 @@
|
|||||||
- 商品名称
|
- 商品名称
|
||||||
- 联系人
|
- 联系人
|
||||||
- 核销套餐
|
- 核销套餐
|
||||||
- 购买数量
|
- 可核销商品数
|
||||||
|
- 已核销商品数
|
||||||
|
- 总数量
|
||||||
- 预约时间
|
- 预约时间
|
||||||
- 实付金额
|
- 实付金额
|
||||||
|
|
||||||
@@ -261,9 +266,17 @@
|
|||||||
- 确认核销
|
- 确认核销
|
||||||
- 取消返回
|
- 取消返回
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 从扫码入口进入且二维码带套餐名称时,只展示当前扫码套餐,不允许切换其他套餐。
|
||||||
|
- 核销确认页优先使用后端套餐配置里的 `count`、`writeOffCount`、`packageStatus` 计算数量。
|
||||||
|
- 当可核销商品数为 `0` 时,确认核销按钮不可点击,并提示当前套餐商品已核销完。
|
||||||
|
- 调用核销接口后,必须检查 `/hotelStaff/order/writeOff` 返回的 `Boolean`;返回 `false` 时按核销失败处理。
|
||||||
|
|
||||||
状态:
|
状态:
|
||||||
|
|
||||||
- 可核销
|
- 可核销
|
||||||
|
- 套餐已核销完
|
||||||
- 当前订单不可核销
|
- 当前订单不可核销
|
||||||
- 核销提交中
|
- 核销提交中
|
||||||
- 核销失败
|
- 核销失败
|
||||||
@@ -291,14 +304,12 @@
|
|||||||
|
|
||||||
展示内容:
|
展示内容:
|
||||||
|
|
||||||
- 搜索框:实体名称
|
- 搜索框:事件标题
|
||||||
- 状态筛选:全部、开启、关闭
|
- 事件卡片:事件标题、事件描述、显示状态、生效时间、发布人、是否弹窗提醒
|
||||||
- 事件卡片:实体名称、事件描述、事件状态、显示状态、生效时间、发布人、是否弹窗提醒
|
|
||||||
|
|
||||||
主要操作:
|
主要操作:
|
||||||
|
|
||||||
- 搜索事件
|
- 搜索事件
|
||||||
- 筛选状态
|
|
||||||
- 进入发布事件页
|
- 进入发布事件页
|
||||||
|
|
||||||
### `/events/create` 发布事件
|
### `/events/create` 发布事件
|
||||||
@@ -307,7 +318,7 @@
|
|||||||
|
|
||||||
展示内容:
|
展示内容:
|
||||||
|
|
||||||
- 实体名称
|
- 事件标题
|
||||||
- 事件描述
|
- 事件描述
|
||||||
- 图片上传
|
- 图片上传
|
||||||
- 发布时间
|
- 发布时间
|
||||||
@@ -322,13 +333,13 @@
|
|||||||
|
|
||||||
校验:
|
校验:
|
||||||
|
|
||||||
- 实体名称必填
|
- 事件标题必填
|
||||||
- 事件描述必填
|
- 事件描述必填
|
||||||
- 生效时间必填
|
- 生效时间必填
|
||||||
|
|
||||||
### `/mine` 我的
|
### `/mine` 我的
|
||||||
|
|
||||||
目标:查看当前登录和接口配置状态。
|
目标:查看当前登录状态。
|
||||||
|
|
||||||
展示内容:
|
展示内容:
|
||||||
|
|
||||||
@@ -336,10 +347,6 @@
|
|||||||
- 登录状态
|
- 登录状态
|
||||||
- 手机号绑定状态
|
- 手机号绑定状态
|
||||||
- 租户
|
- 租户
|
||||||
- 接口模式:Mock/真实接口
|
|
||||||
- `clientId`
|
|
||||||
- `clientConfigId`
|
|
||||||
- 员工端接口前缀
|
|
||||||
|
|
||||||
主要操作:
|
主要操作:
|
||||||
|
|
||||||
@@ -397,7 +404,6 @@
|
|||||||
暂缓内容:
|
暂缓内容:
|
||||||
|
|
||||||
- 独立核销码查询
|
- 独立核销码查询
|
||||||
- 扫码真实调用微信/浏览器能力
|
|
||||||
- 订单复杂高级筛选
|
- 订单复杂高级筛选
|
||||||
- 事件编辑/关闭/删除
|
- 事件编辑/关闭/删除
|
||||||
- 员工权限细分
|
- 员工权限细分
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -1,4 +1,4 @@
|
|||||||
# 酒店员工端 H5
|
# 员工端 H5
|
||||||
|
|
||||||
Vue 3 + Vite + TypeScript + Vant 的移动端员工工作台。当前包含 mock 数据,可直接预览完整流程。
|
Vue 3 + Vite + TypeScript + Vant 的移动端员工工作台。当前包含 mock 数据,可直接预览完整流程。
|
||||||
|
|
||||||
@@ -45,13 +45,13 @@ cp .env.example .env.local
|
|||||||
| `.env.test` | 测试环境打包配置 |
|
| `.env.test` | 测试环境打包配置 |
|
||||||
| `.env.production` | 生产环境打包配置 |
|
| `.env.production` | 生产环境打包配置 |
|
||||||
|
|
||||||
测试环境目前默认使用本地已确认网关:
|
测试环境和生产环境推荐使用同源代理:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
VITE_API_BASE_URL=http://192.168.3.211:9999
|
VITE_API_BASE_URL=
|
||||||
```
|
```
|
||||||
|
|
||||||
生产环境如果 H5 和网关同域部署,`VITE_API_BASE_URL` 可以保持为空;如果不是同域部署,需要改成生产网关域名。
|
部署时由 Nginx 把 `/auth`、`/admin`、`/hotelStaff`、`/hotelBiz` 等接口前缀反向代理到后端网关。只有在确认后端网关允许跨域时,才建议把 `VITE_API_BASE_URL` 改成完整网关域名。
|
||||||
|
|
||||||
## 页面
|
## 页面
|
||||||
|
|
||||||
@@ -66,6 +66,11 @@ VITE_API_BASE_URL=http://192.168.3.211:9999
|
|||||||
- `/events/create`:发布事件。
|
- `/events/create`:发布事件。
|
||||||
- `/mine`:我的。
|
- `/mine`:我的。
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- [H5页面规划.md](./H5页面规划.md):页面结构、核心流程和 MVP 范围。
|
||||||
|
- [docs/frontend-practices.md](./docs/frontend-practices.md):接口接入、移动端兼容、扫码核销和部署经验。
|
||||||
|
|
||||||
## 校验
|
## 校验
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
101
docs/frontend-practices.md
Normal file
101
docs/frontend-practices.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# 员工端 H5 前端经验总结
|
||||||
|
|
||||||
|
这份文档记录当前员工端 H5 在页面规划、接口接入、移动端兼容、扫码核销和部署上的经验。后续改页面时,优先按这里的约定处理,避免重复踩坑。
|
||||||
|
|
||||||
|
## 页面规划
|
||||||
|
|
||||||
|
- 第一版页面以业务闭环为主,不做后台管理式的大而全页面。
|
||||||
|
- 当前核心页面包括:登录、工作台、订单列表、订单详情、扫码核销、核销确认、核销结果、事件列表、发布事件、我的。
|
||||||
|
- 工作台上的统计数据必须有明确后端依据。没有独立统计接口时,可以展示列表接口能稳定支持的指标,例如待确认订单、生效事件、近期订单、近期事件。
|
||||||
|
- 不要把 mock 推导出的数据当成真实业务指标。比如订单表没有可直接查询的核销状态或核销时间时,不要在工作台展示“今日核销”这类指标。
|
||||||
|
- 页面文案保持员工端视角,产品名称统一使用“员工端”。
|
||||||
|
|
||||||
|
## 登录与组织关系
|
||||||
|
|
||||||
|
- 登录使用手机号验证码流程,登录成功后必须主动调用 `/hotelStaff/organizationMember/bindUserInfoAndGetUserMemberInfoByPhone` 获取当前用户组织关系。
|
||||||
|
- 如果组织关系获取失败,统一提示“未绑定组织,请联系管理员”。
|
||||||
|
- 当前不需要调用 `/user/checkUserHasBindPhone`。
|
||||||
|
- 登录态只代表认证成功,不代表用户一定拥有员工端组织关系,业务页面入口应以后续组织关系接口成功为准。
|
||||||
|
- 接口返回“请求令牌已过期”、`401` 或 token 失效类错误时,要统一清理本地登录态并跳转 `/login`,同时带上当前页面作为 `redirect`,避免用户继续停留在失效页面。
|
||||||
|
|
||||||
|
## 接口路径
|
||||||
|
|
||||||
|
- 所有真实接口都通过后端网关访问,由网关按路由转发到具体服务。
|
||||||
|
- 本地开发使用 Vite proxy,当前开发网关是 `http://192.168.3.211:9999`。
|
||||||
|
- 员工端接口前缀是 `/hotelStaff`,订单接口路径是 `/hotelStaff/order/...`,事件接口路径是 `/hotelStaff/event/...`。
|
||||||
|
- 登录接口走 `/auth`,验证码和用户相关接口走 `/admin`。
|
||||||
|
- 不要在业务代码里硬编码完整网关地址,统一从环境变量和 `src/utils/env.ts` 读取。
|
||||||
|
|
||||||
|
## 环境与部署
|
||||||
|
|
||||||
|
- 本地开发使用 `.env.local`,测试打包使用 `.env.test`,生产打包使用 `.env.production`。
|
||||||
|
- `VITE_API_BASE_URL` 为空时,前端会按当前站点同源请求接口,适合 Nginx 反向代理部署。
|
||||||
|
- 前后端分离部署时,优先用 Nginx 把 `/auth`、`/admin`、`/hotelStaff`、`/hotelBiz` 等路径代理到后端网关,这样浏览器看到的是同源请求,跨域问题最少。
|
||||||
|
- 如果直接把 `VITE_API_BASE_URL` 配成后端网关 IP 或域名,跨域就需要后端网关正确返回 CORS 头;这不是前端代码能完全解决的。
|
||||||
|
- SPA 路由需要 Nginx `location / { try_files $uri $uri/ /index.html; }`。
|
||||||
|
|
||||||
|
## 移动端布局
|
||||||
|
|
||||||
|
- H5 页面不要依赖固定宽度,所有主容器都应具备 `width: 100%`、`max-width: 100%`、`min-width: 0` 这类约束。
|
||||||
|
- flex 或 grid 中承载长文本的区域要使用 `min-width: 0`,grid 列建议用 `minmax(0, 1fr)`,否则长订单号、手机号、商品名容易撑出横向滚动。
|
||||||
|
- `html`、`body`、`#app` 可以限制 `overflow-x: hidden`,但这只是兜底。真正要处理的是子元素宽度、长文本换行和 flex 收缩。
|
||||||
|
- 图片、视频、canvas 必须有 `max-width: 100%`,避免媒体元素撑破视口。
|
||||||
|
- 固定底部导航、按钮区域需要考虑 `safe-area-inset-bottom`,兼容 iPhone 底部安全区。
|
||||||
|
|
||||||
|
## 输入框兼容
|
||||||
|
|
||||||
|
- iOS Safari 和微信内置浏览器中,输入框字号小于 16px 时,聚焦输入框会触发页面自动放大,容易出现横向滚动和布局错位。
|
||||||
|
- 全局真实输入控件,包括 `input`、`textarea`、`select`、`.van-field__control`,应保持 `font-size: 16px`。
|
||||||
|
- 不建议通过 `maximum-scale=1` 或 `user-scalable=no` 禁止用户缩放,这会影响可访问性,也不能从根上解决布局问题。
|
||||||
|
- 搜索框、登录手机号、验证码、日期时间输入都要按真实输入控件处理,不要只调整外层字体。
|
||||||
|
|
||||||
|
## Vant 组件样式
|
||||||
|
|
||||||
|
- 修改全局样式时要避免过宽的选择器,例如 `.meta-row span` 会误伤 Vant 的 `van-tag`,导致“生效中”“已取消”等标签在 Chrome 里被拉伸或错位。
|
||||||
|
- 对普通文本列可以使用 `.meta-row > span:not(.van-tag)`,状态标签应单独设置 `flex: 0 0 auto` 和 `white-space: nowrap`。
|
||||||
|
- Vant 组件内部结构可能使用 `span`、`button`、`input` 等基础标签,写全局选择器时要先确认是否会影响组件内部 DOM。
|
||||||
|
- 共享样式要放在 `src/styles/main.css`,页面个性化样式放在对应 `.vue` 的 scoped style。
|
||||||
|
|
||||||
|
## 扫码核销
|
||||||
|
|
||||||
|
- 当前二维码内容规则是 `orderId&packageName`,其中 `packageName` 是套餐名称。
|
||||||
|
- 前端解析时必须兼容只有 `orderId` 的情况。
|
||||||
|
- 扫码流程是:点击进行扫码 -> 调起摄像头 -> 解析二维码 -> 查询订单详情 -> 展示核销确认 -> 调用核销接口。
|
||||||
|
- 当前核销入口不提供手动输入订单号兜底,页面文案保持“点击进行扫码”。
|
||||||
|
- 扫码内容带 `packageName` 时,核销确认页只能展示并核销当前扫码进入的套餐商品;不能让员工切换到其他套餐。
|
||||||
|
- 扫码内容只有 `orderId` 时,核销确认页可以按订单详情里的套餐配置展示可选套餐。
|
||||||
|
- 浏览器扫码优先使用原生 `BarcodeDetector`,不支持时使用 `jsQR` 读取视频画面进行解析。
|
||||||
|
- 摄像头能力通常要求 HTTPS 或 localhost。微信内置浏览器如果 `getUserMedia` 受限,后续可以考虑接入微信 JSSDK 的 `scanQRCode`,但需要公众号配置、签名接口和 JS 安全域名配合。
|
||||||
|
|
||||||
|
## 订单与核销记录
|
||||||
|
|
||||||
|
- 订单列表当前保留手机号搜索和订单状态筛选。
|
||||||
|
- 订单详情可以发起核销,核销确认页需要展示订单核心信息,避免员工误核销。
|
||||||
|
- 核销确认页必须展示可核销商品数、已核销商品数、总数量,优先使用后端套餐配置里的 `count`、`writeOffCount`、`packageStatus` 字段。
|
||||||
|
- 当可核销商品数为 0 时,确认核销按钮应禁用,并提示当前套餐商品已核销完。
|
||||||
|
- `/hotelStaff/order/writeOff` 返回值是 `Boolean`,前端不能只看 HTTP 成功;如果返回 `false`,必须按核销失败处理。
|
||||||
|
- 后端状态机会限制超量核销并控制核销记录数量,前端职责是正确展示剩余数量并正确处理失败态。后端如果能透传“该套餐商品已被核销完”等具体错误,会让前端提示更准确。
|
||||||
|
- 订单详情的核销记录中,如果后端返回套餐名称,要展示套餐名称,便于员工核对。
|
||||||
|
- “使用信息”里不要展示核销地点,当前后端和页面需求不需要这个字段。
|
||||||
|
|
||||||
|
## 事件模块
|
||||||
|
|
||||||
|
- 发布事件页面里的“实体名称”已改为“事件标题”。
|
||||||
|
- 事件列表搜索提示文案为“搜索事件标题”。
|
||||||
|
- 事件列表不需要开启和关闭按钮,也不需要以开启/关闭作为主要筛选交互。
|
||||||
|
- 列表上的状态展示要谨慎处理样式,不能影响 Vant 标签的自然宽度。
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
每次改动移动端布局、表单、列表或扫码能力后,至少检查:
|
||||||
|
|
||||||
|
- `yarn typecheck`
|
||||||
|
- `yarn build:test`
|
||||||
|
- 320px 和 375px 宽度下是否出现横向滚动
|
||||||
|
- 登录页输入框聚焦后是否自动放大
|
||||||
|
- 订单搜索输入框聚焦后是否自动放大
|
||||||
|
- Chrome 里订单状态、事件状态标签是否保持正常宽度
|
||||||
|
- 真机或微信内置浏览器里扫码入口是否能正常申请摄像头权限
|
||||||
|
- 扫码带套餐进入核销确认页时,只能看到当前套餐
|
||||||
|
- 核销确认页的可核销商品数、已核销商品数、总数量和后端返回一致
|
||||||
|
- `/writeOff` 返回 `false` 或失败时,前端不能展示核销成功
|
||||||
@@ -186,6 +186,8 @@ export const mockApi = {
|
|||||||
async orderDetail(orderId: string): Promise<UserOrderDetail> {
|
async orderDetail(orderId: string): Promise<UserOrderDetail> {
|
||||||
await wait()
|
await wait()
|
||||||
const order = orders.find((item) => item.id === orderId) || orders[0]
|
const order = orders.find((item) => item.id === orderId) || orders[0]
|
||||||
|
const totalCount = Number(order.commodityAmount) || 1
|
||||||
|
const writeOffCount = order.writeOffTime ? totalCount : 0
|
||||||
return {
|
return {
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
orderAmt: order.orderAmt,
|
orderAmt: order.orderAmt,
|
||||||
@@ -228,7 +230,12 @@ export const mockApi = {
|
|||||||
commodityPackageConfig: [
|
commodityPackageConfig: [
|
||||||
{
|
{
|
||||||
packageName: order.commodityName,
|
packageName: order.commodityName,
|
||||||
packageContent: `${order.commodityName} x ${order.commodityAmount}`
|
packageContent: `${order.commodityName} x ${order.commodityAmount}`,
|
||||||
|
name: order.commodityName,
|
||||||
|
count: totalCount,
|
||||||
|
unit: '份',
|
||||||
|
packageStatus: writeOffCount >= totalCount ? 1 : 0,
|
||||||
|
writeOffCount
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
reservationEnabled: order.reservationDate ? 1 : 0,
|
reservationEnabled: order.reservationDate ? 1 : 0,
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ export const fetchOrderDetail = async (orderId: string): Promise<UserOrderDetail
|
|||||||
|
|
||||||
export const writeOffOrder = async (payload: WriteOffPayload): Promise<WriteOffResult> => {
|
export const writeOffOrder = async (payload: WriteOffPayload): Promise<WriteOffResult> => {
|
||||||
if (env.useMock) return mockApi.writeOff(payload)
|
if (env.useMock) return mockApi.writeOff(payload)
|
||||||
await http.post<boolean>(joinUrl(env.staffBase, 'order/writeOff'), payload)
|
const success = await http.post<boolean>(joinUrl(env.staffBase, 'order/writeOff'), payload)
|
||||||
|
if (!success) {
|
||||||
|
throw new Error('商品核销失败')
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
orderId: payload.orderId,
|
orderId: payload.orderId,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios, { AxiosError } from 'axios'
|
import axios, { AxiosError } from 'axios'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from 'vant'
|
||||||
|
import { AUTH_EXPIRED_EVENT, AUTH_STORAGE_KEYS, clearAuthStorage } from '@/utils/authStorage'
|
||||||
import { env } from '@/utils/env'
|
import { env } from '@/utils/env'
|
||||||
|
|
||||||
const http = axios.create({
|
const http = axios.create({
|
||||||
@@ -7,9 +8,49 @@ const http = axios.create({
|
|||||||
timeout: 15000
|
timeout: 15000
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let isRedirectingToLogin = false
|
||||||
|
|
||||||
|
const authExpiredTexts = ['请求令牌已过期', '令牌已过期', 'token已过期', 'Token已过期', 'invalid_token']
|
||||||
|
|
||||||
|
const getHeader = (headers: unknown, key: string) => {
|
||||||
|
if (!headers || typeof headers !== 'object') return ''
|
||||||
|
const maybeHeaders = headers as Record<string, unknown> & { get?: (name: string) => unknown }
|
||||||
|
return String(maybeHeaders[key] || maybeHeaders[key.toLowerCase()] || maybeHeaders.get?.(key) || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBearerAuthorization = (headers: unknown) => getHeader(headers, 'Authorization').startsWith('Bearer ')
|
||||||
|
|
||||||
|
const isAuthExpired = (status?: number, code?: unknown, message = '', hasBearerToken = false) => {
|
||||||
|
const normalizedCode = String(code || '')
|
||||||
|
return (
|
||||||
|
authExpiredTexts.some((text) => message.includes(text)) ||
|
||||||
|
((status === 401 || normalizedCode === '401') && hasBearerToken)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectToLogin = (message: string) => {
|
||||||
|
clearAuthStorage()
|
||||||
|
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
|
||||||
|
|
||||||
|
if (isRedirectingToLogin || window.location.pathname.endsWith('/login')) return
|
||||||
|
isRedirectingToLogin = true
|
||||||
|
showToast(message || '登录已失效,请重新登录')
|
||||||
|
|
||||||
|
const fallbackRedirect = `${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||||
|
import('@/router').then(({ default: router }) => {
|
||||||
|
const currentPath = router.currentRoute.value.fullPath
|
||||||
|
router.replace({
|
||||||
|
name: 'login',
|
||||||
|
query: {
|
||||||
|
redirect: currentPath && currentPath !== '/login' ? currentPath : fallbackRedirect
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
http.interceptors.request.use((config) => {
|
http.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem('hotel_h5_access_token')
|
const token = localStorage.getItem(AUTH_STORAGE_KEYS.token)
|
||||||
if (token) {
|
if (token && !getHeader(config.headers, 'Authorization')) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
@@ -22,12 +63,27 @@ http.interceptors.response.use(
|
|||||||
if (payload.code === 0 || payload.code === 200) {
|
if (payload.code === 0 || payload.code === 200) {
|
||||||
return payload.data === undefined ? payload : payload.data
|
return payload.data === undefined ? payload : payload.data
|
||||||
}
|
}
|
||||||
return Promise.reject(new Error(payload.msg || '请求失败'))
|
const message = payload.msg || payload.message || '请求失败'
|
||||||
|
if (isAuthExpired(response.status, payload.code, message, hasBearerAuthorization(response.config.headers))) {
|
||||||
|
redirectToLogin(message)
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(message))
|
||||||
}
|
}
|
||||||
return payload
|
return payload
|
||||||
},
|
},
|
||||||
(error: AxiosError<{ msg?: string }>) => {
|
(error: AxiosError<{ code?: number | string; msg?: string; message?: string }>) => {
|
||||||
const message = error.response?.data?.msg || error.message || '网络异常'
|
const message = error.response?.data?.msg || error.response?.data?.message || error.message || '网络异常'
|
||||||
|
if (
|
||||||
|
isAuthExpired(
|
||||||
|
error.response?.status,
|
||||||
|
error.response?.data?.code,
|
||||||
|
message,
|
||||||
|
hasBearerAuthorization(error.config?.headers)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
redirectToLogin(message)
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
showToast(message)
|
showToast(message)
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type OrganizationMemberInfo
|
type OrganizationMemberInfo
|
||||||
} from '@/api/auth'
|
} from '@/api/auth'
|
||||||
import type { TokenResponse } from '@/types/api'
|
import type { TokenResponse } from '@/types/api'
|
||||||
|
import { AUTH_EXPIRED_EVENT, AUTH_STORAGE_KEYS, clearAuthStorage } from '@/utils/authStorage'
|
||||||
import { env } from '@/utils/env'
|
import { env } from '@/utils/env'
|
||||||
|
|
||||||
export interface StaffUser {
|
export interface StaffUser {
|
||||||
@@ -15,13 +16,8 @@ export interface StaffUser {
|
|||||||
tenantId?: string | number
|
tenantId?: string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN_KEY = 'hotel_h5_access_token'
|
|
||||||
const REFRESH_TOKEN_KEY = 'hotel_h5_refresh_token'
|
|
||||||
const USER_KEY = 'hotel_h5_user'
|
|
||||||
const MEMBER_KEY = 'hotel_h5_member'
|
|
||||||
|
|
||||||
const readUser = (): StaffUser | null => {
|
const readUser = (): StaffUser | null => {
|
||||||
const raw = localStorage.getItem(USER_KEY)
|
const raw = localStorage.getItem(AUTH_STORAGE_KEYS.user)
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw) as StaffUser
|
return JSON.parse(raw) as StaffUser
|
||||||
@@ -31,7 +27,7 @@ const readUser = (): StaffUser | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const readMember = (): OrganizationMemberInfo | null => {
|
const readMember = (): OrganizationMemberInfo | null => {
|
||||||
const raw = localStorage.getItem(MEMBER_KEY)
|
const raw = localStorage.getItem(AUTH_STORAGE_KEYS.member)
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw) as OrganizationMemberInfo
|
return JSON.parse(raw) as OrganizationMemberInfo
|
||||||
@@ -41,8 +37,8 @@ const readMember = (): OrganizationMemberInfo | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
|
const token = ref(localStorage.getItem(AUTH_STORAGE_KEYS.token) || '')
|
||||||
const refreshToken = ref(localStorage.getItem(REFRESH_TOKEN_KEY) || '')
|
const refreshToken = ref(localStorage.getItem(AUTH_STORAGE_KEYS.refreshToken) || '')
|
||||||
const user = ref<StaffUser | null>(readUser())
|
const user = ref<StaffUser | null>(readUser())
|
||||||
const memberInfo = ref<OrganizationMemberInfo | null>(readMember())
|
const memberInfo = ref<OrganizationMemberInfo | null>(readMember())
|
||||||
|
|
||||||
@@ -59,11 +55,11 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
tenantId: payload.tenant_id
|
tenantId: payload.tenant_id
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem(TOKEN_KEY, token.value)
|
localStorage.setItem(AUTH_STORAGE_KEYS.token, token.value)
|
||||||
if (refreshToken.value) {
|
if (refreshToken.value) {
|
||||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken.value)
|
localStorage.setItem(AUTH_STORAGE_KEYS.refreshToken, refreshToken.value)
|
||||||
}
|
}
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(user.value))
|
localStorage.setItem(AUTH_STORAGE_KEYS.user, JSON.stringify(user.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
const login = async (phone: string, code: string) => {
|
const login = async (phone: string, code: string) => {
|
||||||
@@ -75,22 +71,27 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
throw new Error('未绑定组织,请联系管理员')
|
throw new Error('未绑定组织,请联系管理员')
|
||||||
}
|
}
|
||||||
memberInfo.value = member
|
memberInfo.value = member
|
||||||
localStorage.setItem(MEMBER_KEY, JSON.stringify(member))
|
localStorage.setItem(AUTH_STORAGE_KEYS.member, JSON.stringify(member))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logout()
|
logout()
|
||||||
throw new Error('未绑定组织,请联系管理员')
|
throw new Error('未绑定组织,请联系管理员')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = () => {
|
const resetState = () => {
|
||||||
token.value = ''
|
token.value = ''
|
||||||
refreshToken.value = ''
|
refreshToken.value = ''
|
||||||
user.value = null
|
user.value = null
|
||||||
memberInfo.value = null
|
memberInfo.value = null
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
}
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
|
||||||
localStorage.removeItem(USER_KEY)
|
const logout = () => {
|
||||||
localStorage.removeItem(MEMBER_KEY)
|
resetState()
|
||||||
|
clearAuthStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener(AUTH_EXPIRED_EVENT, resetState)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -55,12 +55,20 @@ button,
|
|||||||
input,
|
input,
|
||||||
textarea {
|
textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
.van-field__control {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
img,
|
img,
|
||||||
video,
|
video,
|
||||||
canvas {
|
canvas {
|
||||||
@@ -351,11 +359,20 @@ canvas {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-row span,
|
.meta-row > span:not(.van-tag),
|
||||||
.detail-row span {
|
.detail-row > span:not(.van-tag) {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta-row > .van-tag,
|
||||||
|
.detail-row > .van-tag {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 45%;
|
||||||
|
min-width: auto;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.meta-row strong,
|
.meta-row strong,
|
||||||
.detail-row strong {
|
.detail-row strong {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export interface CommodityPackageConfig {
|
|||||||
packageDesc?: string
|
packageDesc?: string
|
||||||
name?: string
|
name?: string
|
||||||
content?: string
|
content?: string
|
||||||
|
count?: number
|
||||||
|
color?: string
|
||||||
|
unit?: string
|
||||||
|
packageStatus?: number
|
||||||
|
writeOffCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WriteOffRecord {
|
export interface WriteOffRecord {
|
||||||
|
|||||||
13
src/utils/authStorage.ts
Normal file
13
src/utils/authStorage.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export const AUTH_STORAGE_KEYS = {
|
||||||
|
token: 'hotel_h5_access_token',
|
||||||
|
refreshToken: 'hotel_h5_refresh_token',
|
||||||
|
user: 'hotel_h5_user',
|
||||||
|
member: 'hotel_h5_member'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const clearAuthStorage = () => {
|
||||||
|
Object.values(AUTH_STORAGE_KEYS).forEach((key) => localStorage.removeItem(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTH_EXPIRED_EVENT = 'hotel-h5-auth-expired'
|
||||||
|
|
||||||
@@ -148,10 +148,16 @@ onMounted(() => loadEvents(true))
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-status-row span,
|
.event-status-row > span:not(.van-tag),
|
||||||
.meta-row span {
|
.meta-row > span:not(.van-tag) {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.event-status-row > .van-tag,
|
||||||
|
.meta-row > .van-tag {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -196,9 +196,14 @@ onMounted(loadData)
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-row span {
|
.meta-row > span:not(.van-tag) {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meta-row > .van-tag {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { BadgeCheck, CheckCircle2, ReceiptText } from 'lucide-vue-next'
|
|||||||
import { fetchOrderDetail, writeOffOrder } from '@/api/orders'
|
import { fetchOrderDetail, writeOffOrder } from '@/api/orders'
|
||||||
import PageNav from '@/components/PageNav.vue'
|
import PageNav from '@/components/PageNav.vue'
|
||||||
import StatusTag from '@/components/StatusTag.vue'
|
import StatusTag from '@/components/StatusTag.vue'
|
||||||
import type { UserOrderDetail } from '@/types/order'
|
import type { CommodityPackageConfig, UserOrderDetail } from '@/types/order'
|
||||||
import { canWriteOff } from '@/utils/constants'
|
import { canWriteOff } from '@/utils/constants'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -17,6 +17,80 @@ const loading = ref(false)
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
|
||||||
const orderId = computed(() => String(route.query.orderId || ''))
|
const orderId = computed(() => String(route.query.orderId || ''))
|
||||||
|
const routePackageName = computed(() => String(route.query.packageName || '').trim())
|
||||||
|
const isScanPackageLocked = computed(() => String(route.query.source || '') === 'scan' && Boolean(routePackageName.value))
|
||||||
|
|
||||||
|
const toNumber = (value: unknown) => {
|
||||||
|
const number = Number(value)
|
||||||
|
return Number.isFinite(number) ? number : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPackageName = (item?: CommodityPackageConfig) =>
|
||||||
|
(item?.packageName || item?.name || detail.value?.commodityName || '').trim()
|
||||||
|
|
||||||
|
const packageConfigs = computed<CommodityPackageConfig[]>(() => {
|
||||||
|
if (!detail.value) return []
|
||||||
|
if (detail.value.commodityPackageConfig?.length) return detail.value.commodityPackageConfig
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
packageName: detail.value.commodityName,
|
||||||
|
packageContent: detail.value.commodityName,
|
||||||
|
count: toNumber(detail.value.commodityAmount)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const packageOptions = computed(() => {
|
||||||
|
if (!isScanPackageLocked.value) return packageConfigs.value
|
||||||
|
return packageConfigs.value.filter((item) => getPackageName(item) === routePackageName.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const packageMismatch = computed(() => isScanPackageLocked.value && packageOptions.value.length === 0)
|
||||||
|
|
||||||
|
const selectedPackage = computed(() => {
|
||||||
|
return packageOptions.value.find((item) => getPackageName(item) === packageName.value) || packageOptions.value[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
const writtenOffByRecord = computed(() => {
|
||||||
|
if (!detail.value) return 0
|
||||||
|
const selectedName = getPackageName(selectedPackage.value)
|
||||||
|
return (detail.value.writeOffRecordList || []).filter((record) => {
|
||||||
|
if (!selectedName) return true
|
||||||
|
return !record.packageName || record.packageName === selectedName
|
||||||
|
}).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const quantityInfo = computed(() => {
|
||||||
|
const total = toNumber(selectedPackage.value?.count) ?? toNumber(detail.value?.commodityAmount) ?? 0
|
||||||
|
const packageWriteOffCount = toNumber(selectedPackage.value?.writeOffCount)
|
||||||
|
const writtenOff =
|
||||||
|
packageWriteOffCount ??
|
||||||
|
(selectedPackage.value?.packageStatus === 1 && total > 0 ? total : writtenOffByRecord.value)
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
writtenOff,
|
||||||
|
writable: Math.max(total - writtenOff, 0),
|
||||||
|
unit: selectedPackage.value?.unit || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
return Boolean(
|
||||||
|
detail.value &&
|
||||||
|
canWriteOff(detail.value.orderStatus) &&
|
||||||
|
packageName.value &&
|
||||||
|
!packageMismatch.value &&
|
||||||
|
quantityInfo.value.writable > 0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatCount = (value: number) => `${value}${quantityInfo.value.unit || ''}`
|
||||||
|
|
||||||
|
const selectPackage = (item: CommodityPackageConfig) => {
|
||||||
|
if (isScanPackageLocked.value) return
|
||||||
|
packageName.value = getPackageName(item)
|
||||||
|
}
|
||||||
|
|
||||||
const loadDetail = async () => {
|
const loadDetail = async () => {
|
||||||
if (!orderId.value) {
|
if (!orderId.value) {
|
||||||
@@ -27,10 +101,13 @@ const loadDetail = async () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
detail.value = await fetchOrderDetail(orderId.value)
|
detail.value = await fetchOrderDetail(orderId.value)
|
||||||
|
const matchedPackage = routePackageName.value
|
||||||
|
? packageConfigs.value.find((item) => getPackageName(item) === routePackageName.value)
|
||||||
|
: undefined
|
||||||
packageName.value =
|
packageName.value =
|
||||||
String(route.query.packageName || '') ||
|
routePackageName.value && (matchedPackage || isScanPackageLocked.value)
|
||||||
detail.value.commodityPackageConfig?.[0]?.packageName ||
|
? routePackageName.value
|
||||||
detail.value.commodityName
|
: getPackageName(packageConfigs.value[0])
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -42,9 +119,21 @@ const submit = async () => {
|
|||||||
showToast('当前订单状态不可核销')
|
showToast('当前订单状态不可核销')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (packageMismatch.value) {
|
||||||
|
showToast('扫码套餐不属于当前订单')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!packageName.value) {
|
||||||
|
showToast('请选择核销套餐')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (quantityInfo.value.writable <= 0) {
|
||||||
|
showToast('当前套餐商品已核销完')
|
||||||
|
return
|
||||||
|
}
|
||||||
await showConfirmDialog({
|
await showConfirmDialog({
|
||||||
title: '确认核销',
|
title: '确认核销',
|
||||||
message: `订单 ${detail.value.orderId} 核销后不可撤回。`
|
message: `订单 ${detail.value.orderId} 将核销「${packageName.value}」,核销后不可撤回。`
|
||||||
})
|
})
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
@@ -103,21 +192,23 @@ onMounted(loadDetail)
|
|||||||
<h2 class="section-title">核销内容</h2>
|
<h2 class="section-title">核销内容</h2>
|
||||||
<van-radio-group v-model="packageName">
|
<van-radio-group v-model="packageName">
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
|
<van-cell v-if="packageMismatch" title="扫码套餐不属于当前订单" label="请返回重新扫码或联系管理员核对订单二维码" />
|
||||||
<van-cell
|
<van-cell
|
||||||
v-for="item in detail.commodityPackageConfig"
|
v-for="item in packageOptions"
|
||||||
:key="item.packageName || item.name || detail.commodityName"
|
:key="getPackageName(item)"
|
||||||
clickable
|
:clickable="!isScanPackageLocked"
|
||||||
@click="packageName = item.packageName || item.name || detail.commodityName"
|
@click="selectPackage(item)"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="package-title">
|
<div class="package-title">
|
||||||
<ReceiptText :size="17" />
|
<ReceiptText :size="17" />
|
||||||
<span>{{ item.packageName || item.name || detail.commodityName }}</span>
|
<span>{{ getPackageName(item) }}</span>
|
||||||
|
<van-tag v-if="isScanPackageLocked" type="success" plain>扫码指定</van-tag>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #label>{{ item.packageContent || item.packageDesc || item.content }}</template>
|
<template #label>{{ item.packageContent || item.packageDesc || item.content }}</template>
|
||||||
<template #right-icon>
|
<template #right-icon>
|
||||||
<van-radio :name="item.packageName || item.name || detail.commodityName" />
|
<van-radio :name="getPackageName(item)" :disabled="isScanPackageLocked" />
|
||||||
</template>
|
</template>
|
||||||
</van-cell>
|
</van-cell>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
@@ -127,8 +218,16 @@ onMounted(loadDetail)
|
|||||||
<section class="panel detail-panel">
|
<section class="panel detail-panel">
|
||||||
<h2 class="section-title">核对信息</h2>
|
<h2 class="section-title">核对信息</h2>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span>购买数量</span>
|
<span>可核销商品数</span>
|
||||||
<strong>{{ detail.commodityAmount }}</strong>
|
<strong>{{ formatCount(quantityInfo.writable) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>已核销商品数</span>
|
||||||
|
<strong>{{ formatCount(quantityInfo.writtenOff) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span>总数量</span>
|
||||||
|
<strong>{{ formatCount(quantityInfo.total) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span>预约时间</span>
|
<span>预约时间</span>
|
||||||
@@ -149,7 +248,7 @@ onMounted(loadDetail)
|
|||||||
|
|
||||||
<footer v-if="detail" class="fixed-action">
|
<footer v-if="detail" class="fixed-action">
|
||||||
<div class="fixed-action__inner">
|
<div class="fixed-action__inner">
|
||||||
<van-button block type="primary" :disabled="!canWriteOff(detail.orderStatus)" :loading="submitting" @click="submit">
|
<van-button block type="primary" :disabled="!canSubmit" :loading="submitting" @click="submit">
|
||||||
<template #icon><CheckCircle2 :size="18" /></template>
|
<template #icon><CheckCircle2 :size="18" /></template>
|
||||||
确认核销
|
确认核销
|
||||||
</van-button>
|
</van-button>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const goConfirm = (payload: WriteOffCodePayload) => {
|
|||||||
path: '/verify/confirm',
|
path: '/verify/confirm',
|
||||||
query: {
|
query: {
|
||||||
orderId: payload.orderId,
|
orderId: payload.orderId,
|
||||||
|
source: 'scan',
|
||||||
...(payload.packageName ? { packageName: payload.packageName } : {})
|
...(payload.packageName ? { packageName: payload.packageName } : {})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user