第三次版本迭代更新

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

@@ -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 } : {})
}
})