第三次版本迭代更新
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
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;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -49,6 +49,7 @@ const goConfirm = (payload: WriteOffCodePayload) => {
|
||||
path: '/verify/confirm',
|
||||
query: {
|
||||
orderId: payload.orderId,
|
||||
source: 'scan',
|
||||
...(payload.packageName ? { packageName: payload.packageName } : {})
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user