diff --git a/H5页面规划.md b/H5页面规划.md index f7e750f..44b49c1 100644 --- a/H5页面规划.md +++ b/H5页面规划.md @@ -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 @@ 暂缓内容: - 独立核销码查询 -- 扫码真实调用微信/浏览器能力 - 订单复杂高级筛选 - 事件编辑/关闭/删除 - 员工权限细分 diff --git a/README.md b/README.md index b9fbbf8..4042bf1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/frontend-practices.md b/docs/frontend-practices.md new file mode 100644 index 0000000..9d5dd3a --- /dev/null +++ b/docs/frontend-practices.md @@ -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` 或失败时,前端不能展示核销成功 diff --git a/src/api/mock.ts b/src/api/mock.ts index 8c622be..80dc465 100644 --- a/src/api/mock.ts +++ b/src/api/mock.ts @@ -186,6 +186,8 @@ export const mockApi = { async orderDetail(orderId: string): Promise { 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, diff --git a/src/api/orders.ts b/src/api/orders.ts index b2871a5..57e4c03 100644 --- a/src/api/orders.ts +++ b/src/api/orders.ts @@ -29,7 +29,10 @@ export const fetchOrderDetail = async (orderId: string): Promise => { if (env.useMock) return mockApi.writeOff(payload) - await http.post(joinUrl(env.staffBase, 'order/writeOff'), payload) + const success = await http.post(joinUrl(env.staffBase, 'order/writeOff'), payload) + if (!success) { + throw new Error('商品核销失败') + } return { success: true, orderId: payload.orderId, diff --git a/src/api/request.ts b/src/api/request.ts index 2110c71..aee8942 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -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 & { 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) } diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 7b2f845..cfcbe11 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -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(readUser()) const memberInfo = ref(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 { diff --git a/src/styles/main.css b/src/styles/main.css index f313e61..1c532f6 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -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; diff --git a/src/types/order.ts b/src/types/order.ts index 3da08b4..f84cc0f 100644 --- a/src/types/order.ts +++ b/src/types/order.ts @@ -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 { diff --git a/src/utils/authStorage.ts b/src/utils/authStorage.ts new file mode 100644 index 0000000..b2907a0 --- /dev/null +++ b/src/utils/authStorage.ts @@ -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' + diff --git a/src/views/events/EventListView.vue b/src/views/events/EventListView.vue index f677557..1f2a311 100644 --- a/src/views/events/EventListView.vue +++ b/src/views/events/EventListView.vue @@ -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; +} diff --git a/src/views/home/HomeView.vue b/src/views/home/HomeView.vue index 2cd5bdf..7f93b00 100644 --- a/src/views/home/HomeView.vue +++ b/src/views/home/HomeView.vue @@ -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; +} diff --git a/src/views/verify/VerifyConfirmView.vue b/src/views/verify/VerifyConfirmView.vue index bd812f0..7dbeb52 100644 --- a/src/views/verify/VerifyConfirmView.vue +++ b/src/views/verify/VerifyConfirmView.vue @@ -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(() => { + 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)

核销内容

+ @@ -127,8 +218,16 @@ onMounted(loadDetail)

核对信息

- 购买数量 - {{ detail.commodityAmount }} + 可核销商品数 + {{ formatCount(quantityInfo.writable) }} +
+
+ 已核销商品数 + {{ formatCount(quantityInfo.writtenOff) }} +
+
+ 总数量 + {{ formatCount(quantityInfo.total) }}
预约时间 @@ -149,7 +248,7 @@ onMounted(loadDetail)