第三次版本迭代更新

This commit is contained in:
andy
2026-06-18 15:54:47 +08:00
parent 10fbd0390c
commit 898a1e1577
14 changed files with 400 additions and 75 deletions

View File

@@ -1,4 +1,4 @@
# 酒店员工端 H5 页面规划
# 员工端 H5 页面规划
## 产品定位
@@ -52,9 +52,9 @@
|---|---|---|
| 工作台 | `/home` | 展示关键数据和快捷入口 |
| 订单 | `/orders` | 查询订单、进入详情 |
| 核销 | `/verify` | 扫码或输入订单号核销 |
| 核销 | `/verify` | 手机摄像头扫码核销 |
| 事件 | `/events` | 查看事件列表 |
| 我的 | `/mine` | 当前账号、接口模式、退出登录 |
| 我的 | `/mine` | 当前账号、登录状态、退出登录 |
## 核心流程
@@ -66,6 +66,7 @@
-> 输入验证码
-> 调用 OAuth2 手机号登录
-> 保存 access_token
-> 调用组织关系接口
-> 进入工作台
```
@@ -96,11 +97,12 @@
-> 核销结果
```
支持:
扫码入口支持:
```text
核销入口
-> 输入订单号
-> 点击进行扫码
-> 解析二维码内容
-> 查询订单详情
-> 核销确认
-> 核销结果
@@ -114,7 +116,9 @@
| 订单详情 | `POST /hotelStaff/order/userOrderDetail` |
| 确认核销 | `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` 核销确认
@@ -252,7 +255,9 @@
- 商品名称
- 联系人
- 核销套餐
- 购买数量
- 可核销商品数
- 已核销商品数
- 总数量
- 预约时间
- 实付金额
@@ -261,9 +266,17 @@
- 确认核销
- 取消返回
规则:
- 从扫码入口进入且二维码带套餐名称时,只展示当前扫码套餐,不允许切换其他套餐。
- 核销确认页优先使用后端套餐配置里的 `count``writeOffCount``packageStatus` 计算数量。
- 当可核销商品数为 `0` 时,确认核销按钮不可点击,并提示当前套餐商品已核销完。
- 调用核销接口后,必须检查 `/hotelStaff/order/writeOff` 返回的 `Boolean`;返回 `false` 时按核销失败处理。
状态:
- 可核销
- 套餐已核销完
- 当前订单不可核销
- 核销提交中
- 核销失败
@@ -291,14 +304,12 @@
展示内容:
- 搜索框:实体名称
- 状态筛选:全部、开启、关闭
- 事件卡片:实体名称、事件描述、事件状态、显示状态、生效时间、发布人、是否弹窗提醒
- 搜索框:事件标题
- 事件卡片:事件标题、事件描述、显示状态、生效时间、发布人、是否弹窗提醒
主要操作:
- 搜索事件
- 筛选状态
- 进入发布事件页
### `/events/create` 发布事件
@@ -307,7 +318,7 @@
展示内容:
- 实体名称
- 事件标题
- 事件描述
- 图片上传
- 发布时间
@@ -322,13 +333,13 @@
校验:
- 实体名称必填
- 事件标题必填
- 事件描述必填
- 生效时间必填
### `/mine` 我的
目标:查看当前登录和接口配置状态。
目标:查看当前登录状态。
展示内容:
@@ -336,10 +347,6 @@
- 登录状态
- 手机号绑定状态
- 租户
- 接口模式Mock/真实接口
- `clientId`
- `clientConfigId`
- 员工端接口前缀
主要操作:
@@ -397,7 +404,6 @@
暂缓内容:
- 独立核销码查询
- 扫码真实调用微信/浏览器能力
- 订单复杂高级筛选
- 事件编辑/关闭/删除
- 员工权限细分

View File

@@ -1,4 +1,4 @@
# 酒店员工端 H5
# 员工端 H5
Vue 3 + Vite + TypeScript + Vant 的移动端员工工作台。当前包含 mock 数据,可直接预览完整流程。
@@ -45,13 +45,13 @@ cp .env.example .env.local
| `.env.test` | 测试环境打包配置 |
| `.env.production` | 生产环境打包配置 |
测试环境目前默认使用本地已确认网关
测试环境和生产环境推荐使用同源代理
```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`:发布事件。
- `/mine`:我的。
## 文档
- [H5页面规划.md](./H5页面规划.md):页面结构、核心流程和 MVP 范围。
- [docs/frontend-practices.md](./docs/frontend-practices.md):接口接入、移动端兼容、扫码核销和部署经验。
## 校验
```bash

101
docs/frontend-practices.md Normal file
View 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` 或失败时,前端不能展示核销成功

View File

@@ -186,6 +186,8 @@ export const mockApi = {
async orderDetail(orderId: string): Promise<UserOrderDetail> {
await wait()
const order = orders.find((item) => item.id === orderId) || orders[0]
const totalCount = Number(order.commodityAmount) || 1
const writeOffCount = order.writeOffTime ? totalCount : 0
return {
orderId: order.id,
orderAmt: order.orderAmt,
@@ -228,7 +230,12 @@ export const mockApi = {
commodityPackageConfig: [
{
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,

View File

@@ -29,7 +29,10 @@ export const fetchOrderDetail = async (orderId: string): Promise<UserOrderDetail
export const writeOffOrder = async (payload: WriteOffPayload): Promise<WriteOffResult> => {
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 {
success: true,
orderId: payload.orderId,

View File

@@ -1,5 +1,6 @@
import axios, { AxiosError } from 'axios'
import { showToast } from 'vant'
import { AUTH_EXPIRED_EVENT, AUTH_STORAGE_KEYS, clearAuthStorage } from '@/utils/authStorage'
import { env } from '@/utils/env'
const http = axios.create({
@@ -7,9 +8,49 @@ const http = axios.create({
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) => {
const token = localStorage.getItem('hotel_h5_access_token')
if (token) {
const token = localStorage.getItem(AUTH_STORAGE_KEYS.token)
if (token && !getHeader(config.headers, 'Authorization')) {
config.headers.Authorization = `Bearer ${token}`
}
return config
@@ -22,12 +63,27 @@ http.interceptors.response.use(
if (payload.code === 0 || payload.code === 200) {
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
},
(error: AxiosError<{ msg?: string }>) => {
const message = error.response?.data?.msg || error.message || '网络异常'
(error: AxiosError<{ code?: number | string; msg?: string; message?: string }>) => {
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)
return Promise.reject(error)
}

View File

@@ -6,6 +6,7 @@ import {
type OrganizationMemberInfo
} from '@/api/auth'
import type { TokenResponse } from '@/types/api'
import { AUTH_EXPIRED_EVENT, AUTH_STORAGE_KEYS, clearAuthStorage } from '@/utils/authStorage'
import { env } from '@/utils/env'
export interface StaffUser {
@@ -15,13 +16,8 @@ export interface StaffUser {
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 raw = localStorage.getItem(USER_KEY)
const raw = localStorage.getItem(AUTH_STORAGE_KEYS.user)
if (!raw) return null
try {
return JSON.parse(raw) as StaffUser
@@ -31,7 +27,7 @@ const readUser = (): StaffUser | null => {
}
const readMember = (): OrganizationMemberInfo | null => {
const raw = localStorage.getItem(MEMBER_KEY)
const raw = localStorage.getItem(AUTH_STORAGE_KEYS.member)
if (!raw) return null
try {
return JSON.parse(raw) as OrganizationMemberInfo
@@ -41,8 +37,8 @@ const readMember = (): OrganizationMemberInfo | null => {
}
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
const refreshToken = ref(localStorage.getItem(REFRESH_TOKEN_KEY) || '')
const token = ref(localStorage.getItem(AUTH_STORAGE_KEYS.token) || '')
const refreshToken = ref(localStorage.getItem(AUTH_STORAGE_KEYS.refreshToken) || '')
const user = ref<StaffUser | null>(readUser())
const memberInfo = ref<OrganizationMemberInfo | null>(readMember())
@@ -59,11 +55,11 @@ export const useAuthStore = defineStore('auth', () => {
tenantId: payload.tenant_id
}
localStorage.setItem(TOKEN_KEY, token.value)
localStorage.setItem(AUTH_STORAGE_KEYS.token, token.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) => {
@@ -75,22 +71,27 @@ export const useAuthStore = defineStore('auth', () => {
throw new Error('未绑定组织,请联系管理员')
}
memberInfo.value = member
localStorage.setItem(MEMBER_KEY, JSON.stringify(member))
localStorage.setItem(AUTH_STORAGE_KEYS.member, JSON.stringify(member))
} catch (error) {
logout()
throw new Error('未绑定组织,请联系管理员')
}
}
const logout = () => {
const resetState = () => {
token.value = ''
refreshToken.value = ''
user.value = null
memberInfo.value = null
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(USER_KEY)
localStorage.removeItem(MEMBER_KEY)
}
const logout = () => {
resetState()
clearAuthStorage()
}
if (typeof window !== 'undefined') {
window.addEventListener(AUTH_EXPIRED_EVENT, resetState)
}
return {

View File

@@ -55,12 +55,20 @@ button,
input,
textarea {
font: inherit;
touch-action: manipulation;
}
button {
color: inherit;
}
input,
textarea,
select,
.van-field__control {
font-size: 16px;
}
img,
video,
canvas {
@@ -351,11 +359,20 @@ canvas {
overflow-wrap: anywhere;
}
.meta-row span,
.detail-row span {
.meta-row > span:not(.van-tag),
.detail-row > span:not(.van-tag) {
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,
.detail-row strong {
flex: 0 1 auto;

View File

@@ -57,6 +57,11 @@ export interface CommodityPackageConfig {
packageDesc?: string
name?: string
content?: string
count?: number
color?: string
unit?: string
packageStatus?: number
writeOffCount?: number
}
export interface WriteOffRecord {

13
src/utils/authStorage.ts Normal file
View 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'

View File

@@ -148,10 +148,16 @@ onMounted(() => loadEvents(true))
font-size: 12px;
}
.event-status-row span,
.meta-row span {
.event-status-row > span:not(.van-tag),
.meta-row > span:not(.van-tag) {
display: inline-flex;
align-items: center;
gap: 4px;
}
.event-status-row > .van-tag,
.meta-row > .van-tag {
flex: 0 0 auto;
white-space: nowrap;
}
</style>

View File

@@ -196,9 +196,14 @@ onMounted(loadData)
-webkit-box-orient: vertical;
}
.meta-row span {
.meta-row > span:not(.van-tag) {
display: inline-flex;
align-items: center;
gap: 4px;
}
.meta-row > .van-tag {
flex: 0 0 auto;
white-space: nowrap;
}
</style>

View File

@@ -6,7 +6,7 @@ import { BadgeCheck, CheckCircle2, ReceiptText } from 'lucide-vue-next'
import { fetchOrderDetail, writeOffOrder } from '@/api/orders'
import PageNav from '@/components/PageNav.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'
const route = useRoute()
@@ -17,6 +17,80 @@ const loading = ref(false)
const submitting = ref(false)
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 () => {
if (!orderId.value) {
@@ -27,10 +101,13 @@ const loadDetail = async () => {
loading.value = true
try {
detail.value = await fetchOrderDetail(orderId.value)
const matchedPackage = routePackageName.value
? packageConfigs.value.find((item) => getPackageName(item) === routePackageName.value)
: undefined
packageName.value =
String(route.query.packageName || '') ||
detail.value.commodityPackageConfig?.[0]?.packageName ||
detail.value.commodityName
routePackageName.value && (matchedPackage || isScanPackageLocked.value)
? routePackageName.value
: getPackageName(packageConfigs.value[0])
} finally {
loading.value = false
}
@@ -42,9 +119,21 @@ const submit = async () => {
showToast('当前订单状态不可核销')
return
}
if (packageMismatch.value) {
showToast('扫码套餐不属于当前订单')
return
}
if (!packageName.value) {
showToast('请选择核销套餐')
return
}
if (quantityInfo.value.writable <= 0) {
showToast('当前套餐商品已核销完')
return
}
await showConfirmDialog({
title: '确认核销',
message: `订单 ${detail.value.orderId} 核销后不可撤回。`
message: `订单 ${detail.value.orderId} 将核销「${packageName.value}」,核销后不可撤回。`
})
submitting.value = true
try {
@@ -103,21 +192,23 @@ onMounted(loadDetail)
<h2 class="section-title">核销内容</h2>
<van-radio-group v-model="packageName">
<van-cell-group inset>
<van-cell v-if="packageMismatch" title="扫码套餐不属于当前订单" label="请返回重新扫码或联系管理员核对订单二维码" />
<van-cell
v-for="item in detail.commodityPackageConfig"
:key="item.packageName || item.name || detail.commodityName"
clickable
@click="packageName = item.packageName || item.name || detail.commodityName"
v-for="item in packageOptions"
:key="getPackageName(item)"
:clickable="!isScanPackageLocked"
@click="selectPackage(item)"
>
<template #title>
<div class="package-title">
<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>
</template>
<template #label>{{ item.packageContent || item.packageDesc || item.content }}</template>
<template #right-icon>
<van-radio :name="item.packageName || item.name || detail.commodityName" />
<van-radio :name="getPackageName(item)" :disabled="isScanPackageLocked" />
</template>
</van-cell>
</van-cell-group>
@@ -127,8 +218,16 @@ onMounted(loadDetail)
<section class="panel detail-panel">
<h2 class="section-title">核对信息</h2>
<div class="detail-row">
<span>购买数量</span>
<strong>{{ detail.commodityAmount }}</strong>
<span>可核销商品数</span>
<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 class="detail-row">
<span>预约时间</span>
@@ -149,7 +248,7 @@ onMounted(loadDetail)
<footer v-if="detail" class="fixed-action">
<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>
确认核销
</van-button>

View File

@@ -49,6 +49,7 @@ const goConfirm = (payload: WriteOffCodePayload) => {
path: '/verify/confirm',
query: {
orderId: payload.orderId,
source: 'scan',
...(payload.packageName ? { packageName: payload.packageName } : {})
}
})