修复前端登录权限边界问题
This commit is contained in:
@@ -15,9 +15,10 @@ export const router = createRouter({
|
|||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: () => {
|
name: 'home',
|
||||||
const authStore = useAuthStore()
|
component: {
|
||||||
return authStore.firstMenuPath ?? '/reservation/orders'
|
name: 'HomeRedirectView',
|
||||||
|
render: () => null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -126,6 +127,10 @@ export function createAuthGuard(resolveAuthStore: () => AuthStore = () => useAut
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (to.path === '/') {
|
||||||
|
return authStore.firstMenuPath ?? '/reservation/orders'
|
||||||
|
}
|
||||||
|
|
||||||
if (meta.permission && !authStore.hasPermission(meta.permission)) {
|
if (meta.permission && !authStore.hasPermission(meta.permission)) {
|
||||||
return {
|
return {
|
||||||
name: 'unauthorized',
|
name: 'unauthorized',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
DebugEmlUploadInput,
|
DebugEmlUploadInput,
|
||||||
} from '@/types/debugEml'
|
} from '@/types/debugEml'
|
||||||
import { getStoredAccessToken } from '@/services/authSession'
|
import { getStoredAccessToken } from '@/services/authSession'
|
||||||
|
import { isAuthSessionInvalidPayload, notifyUnauthorized } from '@/services/httpClient'
|
||||||
|
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ''
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ''
|
||||||
const debugEmlEndpoint = '/api/system/debug/eml-superagent-runs'
|
const debugEmlEndpoint = '/api/system/debug/eml-superagent-runs'
|
||||||
@@ -42,6 +43,9 @@ export async function uploadDebugEmlSuperAgentRun(
|
|||||||
|
|
||||||
const payload = await readResponsePayload(response)
|
const payload = await readResponsePayload(response)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (isAuthSessionInvalidPayload(payload)) {
|
||||||
|
notifyUnauthorized()
|
||||||
|
}
|
||||||
const errorPayload = isDebugEmlErrorResponse(payload) ? payload : {}
|
const errorPayload = isDebugEmlErrorResponse(payload) ? payload : {}
|
||||||
throw new DebugEmlUploadError(
|
throw new DebugEmlUploadError(
|
||||||
errorPayload.message || `Request failed with status ${response.status}`,
|
errorPayload.message || `Request failed with status ${response.status}`,
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ export function setUnauthorizedHandler(handler: UnauthorizedHandler): void {
|
|||||||
unauthorizedHandler = handler
|
unauthorizedHandler = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function notifyUnauthorized(): void {
|
||||||
|
unauthorizedHandler?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthSessionInvalidPayload(payload: unknown): boolean {
|
||||||
|
return authUnauthorizedErrorCodes.has(errorCodeFromPayload(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthUnauthorizedError(error: unknown): boolean {
|
||||||
|
return error instanceof ApiError && isAuthUnauthorized(error.status, error.details)
|
||||||
|
}
|
||||||
|
|
||||||
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
@@ -48,7 +60,7 @@ async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
|||||||
const payload = await readResponsePayload(response)
|
const payload = await readResponsePayload(response)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (isAuthUnauthorized(response.status, payload)) {
|
if (isAuthUnauthorized(response.status, payload)) {
|
||||||
unauthorizedHandler?.()
|
notifyUnauthorized()
|
||||||
}
|
}
|
||||||
throw new ApiError(`Request failed with status ${response.status}`, response.status, payload)
|
throw new ApiError(`Request failed with status ${response.status}`, response.status, payload)
|
||||||
}
|
}
|
||||||
@@ -93,7 +105,7 @@ function mergeHeaders(target: Record<string, string>, source?: HeadersInit): voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isAuthUnauthorized(status: number, payload: unknown): boolean {
|
function isAuthUnauthorized(status: number, payload: unknown): boolean {
|
||||||
if (authUnauthorizedErrorCodes.has(errorCodeFromPayload(payload))) {
|
if (isAuthSessionInvalidPayload(payload)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return status === 401 && errorCodeFromPayload(payload) !== 'AUTH_INVALID_CREDENTIALS'
|
return status === 401 && errorCodeFromPayload(payload) !== 'AUTH_INVALID_CREDENTIALS'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
|
|
||||||
import { fetchCurrentAuth, loginAuth, logoutAuth } from '@/services/authService'
|
import { fetchCurrentAuth, loginAuth, logoutAuth } from '@/services/authService'
|
||||||
import { clearStoredAccessToken, getStoredAccessToken, setStoredAccessToken } from '@/services/authSession'
|
import { clearStoredAccessToken, getStoredAccessToken, setStoredAccessToken } from '@/services/authSession'
|
||||||
|
import { isAuthUnauthorizedError } from '@/services/httpClient'
|
||||||
import type {
|
import type {
|
||||||
AuthHotelResult,
|
AuthHotelResult,
|
||||||
AuthLoginRequest,
|
AuthLoginRequest,
|
||||||
@@ -78,9 +79,14 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
this.applyAuthContext(result)
|
this.applyAuthContext(result)
|
||||||
this.restoreAttempted = true
|
this.restoreAttempted = true
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (isAuthUnauthorizedError(error)) {
|
||||||
this.clearAuth()
|
this.clearAuth()
|
||||||
this.restoreAttempted = true
|
this.restoreAttempted = true
|
||||||
|
} else {
|
||||||
|
this.accessToken = token
|
||||||
|
this.restoreAttempted = false
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
this.restoring = false
|
this.restoring = false
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { ApiError } from '@/services/httpClient'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
vi.mock('@/services/authService', async (importOriginal) => {
|
vi.mock('@/services/authService', async (importOriginal) => {
|
||||||
@@ -96,8 +97,13 @@ describe('authStore', () => {
|
|||||||
expect(store.selectedHotelId).toBe('HOTEL-TEST')
|
expect(store.selectedHotelId).toBe('HOTEL-TEST')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears token and user context when restore fails', async () => {
|
it('clears token and user context when the auth session is invalid', async () => {
|
||||||
vi.mocked(authService.fetchCurrentAuth).mockRejectedValue(new Error('AUTH_SESSION_INVALID'))
|
vi.mocked(authService.fetchCurrentAuth).mockRejectedValue(
|
||||||
|
new ApiError('登录已失效。', 401, {
|
||||||
|
error_code: 'AUTH_SESSION_INVALID',
|
||||||
|
message: '登录已失效。',
|
||||||
|
}),
|
||||||
|
)
|
||||||
sessionStorage.setItem('th_hotel_access_token', 'expired-token')
|
sessionStorage.setItem('th_hotel_access_token', 'expired-token')
|
||||||
const store = useAuthStore()
|
const store = useAuthStore()
|
||||||
|
|
||||||
@@ -109,6 +115,25 @@ describe('authStore', () => {
|
|||||||
expect(store.permissions).toEqual([])
|
expect(store.permissions).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the session token when auth restore fails for a transient backend error', async () => {
|
||||||
|
vi.mocked(authService.fetchCurrentAuth).mockRejectedValue(
|
||||||
|
new ApiError('Server error', 500, {
|
||||||
|
error_code: 'AUTH_TEMPORARY_UNAVAILABLE',
|
||||||
|
message: '认证服务暂不可用。',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
sessionStorage.setItem('th_hotel_access_token', 'session-token')
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
const restored = await store.restoreFromSession()
|
||||||
|
|
||||||
|
expect(restored).toBe(false)
|
||||||
|
expect(sessionStorage.getItem('th_hotel_access_token')).toBe('session-token')
|
||||||
|
expect(store.accessToken).toBe('session-token')
|
||||||
|
expect(store.user).toBeNull()
|
||||||
|
expect(store.restoreAttempted).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('clears local auth context even when backend logout fails', async () => {
|
it('clears local auth context even when backend logout fails', async () => {
|
||||||
vi.mocked(authService.loginAuth).mockResolvedValue(createAuthPayload())
|
vi.mocked(authService.loginAuth).mockResolvedValue(createAuthPayload())
|
||||||
vi.mocked(authService.logoutAuth).mockRejectedValue(new Error('network down'))
|
vi.mocked(authService.logoutAuth).mockRejectedValue(new Error('network down'))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { DebugEmlUploadError, uploadDebugEmlSuperAgentRun } from '@/services/debugEmlService'
|
import { DebugEmlUploadError, uploadDebugEmlSuperAgentRun } from '@/services/debugEmlService'
|
||||||
|
import { setUnauthorizedHandler } from '@/services/httpClient'
|
||||||
|
|
||||||
const jsonHeaders = {
|
const jsonHeaders = {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -21,6 +22,7 @@ function mockJsonResponse(payload: unknown, status = 201): Response {
|
|||||||
describe('debugEmlService', () => {
|
describe('debugEmlService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
|
setUnauthorizedHandler(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uploads an eml file with FormData and debug key header', async () => {
|
it('uploads an eml file with FormData and debug key header', async () => {
|
||||||
@@ -76,6 +78,8 @@ describe('debugEmlService', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('throws a typed safe error for backend error_code responses', async () => {
|
it('throws a typed safe error for backend error_code responses', async () => {
|
||||||
|
const unauthorizedHandler = vi.fn()
|
||||||
|
setUnauthorizedHandler(unauthorizedHandler)
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
mockJsonResponse(
|
mockJsonResponse(
|
||||||
{
|
{
|
||||||
@@ -98,5 +102,34 @@ describe('debugEmlService', () => {
|
|||||||
errorCode: 'DEBUG_UPLOAD_KEY_INVALID',
|
errorCode: 'DEBUG_UPLOAD_KEY_INVALID',
|
||||||
message: 'Debug 上传访问口令缺失或错误。',
|
message: 'Debug 上传访问口令缺失或错误。',
|
||||||
} satisfies Partial<DebugEmlUploadError>)
|
} satisfies Partial<DebugEmlUploadError>)
|
||||||
|
expect(unauthorizedHandler).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('runs the shared unauthorized handler for auth session errors', async () => {
|
||||||
|
const unauthorizedHandler = vi.fn()
|
||||||
|
setUnauthorizedHandler(unauthorizedHandler)
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
mockJsonResponse(
|
||||||
|
{
|
||||||
|
error_code: 'AUTH_SESSION_INVALID',
|
||||||
|
message: '登录已失效。',
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const file = new File(['From: guest@example.test'], 'booking.eml', { type: 'message/rfc822' })
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
uploadDebugEmlSuperAgentRun({
|
||||||
|
hotelId: 'HOTEL-TEST',
|
||||||
|
debugUploadKey: 'manual-debug-key',
|
||||||
|
file,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
status: 401,
|
||||||
|
errorCode: 'AUTH_SESSION_INVALID',
|
||||||
|
} satisfies Partial<DebugEmlUploadError>)
|
||||||
|
|
||||||
|
expect(unauthorizedHandler).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -85,4 +85,32 @@ describe('reservation router', () => {
|
|||||||
name: 'unauthorized',
|
name: 'unauthorized',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('redirects the root route to the restored first backend menu', async () => {
|
||||||
|
let restored = false
|
||||||
|
let authenticated = false
|
||||||
|
let firstMenuPath: string | null = null
|
||||||
|
const authStore = {
|
||||||
|
restoreAttempted: false,
|
||||||
|
get isAuthenticated() {
|
||||||
|
return authenticated
|
||||||
|
},
|
||||||
|
get firstMenuPath() {
|
||||||
|
return firstMenuPath
|
||||||
|
},
|
||||||
|
hasStoredToken: () => true,
|
||||||
|
restoreFromSession: async () => {
|
||||||
|
restored = true
|
||||||
|
authStore.restoreAttempted = true
|
||||||
|
authenticated = true
|
||||||
|
firstMenuPath = '/reservation/tasks'
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
hasPermission: () => true,
|
||||||
|
} as unknown as AuthStoreForGuard
|
||||||
|
const guard = createAuthGuard(() => authStore)
|
||||||
|
|
||||||
|
await expect(guard(createRoute('/'))).resolves.toBe('/reservation/tasks')
|
||||||
|
expect(restored).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
@@ -8,6 +9,7 @@ import ReservationOrderDetailView from '@/views/reservation/ReservationOrderDeta
|
|||||||
import ReservationOrderListView from '@/views/reservation/ReservationOrderListView.vue'
|
import ReservationOrderListView from '@/views/reservation/ReservationOrderListView.vue'
|
||||||
import ReservationSourceMessageConversationView from '@/views/reservation/ReservationSourceMessageConversationView.vue'
|
import ReservationSourceMessageConversationView from '@/views/reservation/ReservationSourceMessageConversationView.vue'
|
||||||
import ReservationTaskListView from '@/views/reservation/ReservationTaskListView.vue'
|
import ReservationTaskListView from '@/views/reservation/ReservationTaskListView.vue'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import type { SourceMessageConversationResult } from '@/types/reservation'
|
import type { SourceMessageConversationResult } from '@/types/reservation'
|
||||||
|
|
||||||
vi.mock('@/services/reservationService', async (importOriginal) => {
|
vi.mock('@/services/reservationService', async (importOriginal) => {
|
||||||
@@ -91,18 +93,18 @@ function createTaskListResult(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOrderDetailResult() {
|
function createOrderDetailResult(displayName = 'GRP-001') {
|
||||||
return {
|
return {
|
||||||
order: {
|
order: {
|
||||||
order_id: '20001',
|
order_id: displayName === 'GRP-002' ? '20002' : '20001',
|
||||||
hotel_id: 'HOTEL-TEST',
|
hotel_id: 'HOTEL-TEST',
|
||||||
order_status: 'ACTIVE',
|
order_status: 'ACTIVE',
|
||||||
temporary_order_no: null,
|
temporary_order_no: null,
|
||||||
confirmation_number: null,
|
confirmation_number: null,
|
||||||
group_code: 'GRP-001',
|
group_code: displayName,
|
||||||
block_code: null,
|
block_code: null,
|
||||||
allotment_code: null,
|
allotment_code: null,
|
||||||
display_name: 'GRP-001',
|
display_name: displayName,
|
||||||
created_at: '2026-07-08T03:00:00Z',
|
created_at: '2026-07-08T03:00:00Z',
|
||||||
updated_at: '2026-07-08T03:10:00Z',
|
updated_at: '2026-07-08T03:10:00Z',
|
||||||
},
|
},
|
||||||
@@ -238,7 +240,42 @@ function createDeferred<T>() {
|
|||||||
return { promise, resolve, reject }
|
return { promise, resolve, reject }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createAuthPayload() {
|
||||||
|
return {
|
||||||
|
access_token: 'session-token',
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_at: '2026-07-09T12:00:00Z',
|
||||||
|
user: {
|
||||||
|
id: '1900000000000000001',
|
||||||
|
username: 'admin',
|
||||||
|
display_name: '系统管理员',
|
||||||
|
super_admin: true,
|
||||||
|
},
|
||||||
|
default_hotel_id: 'HOTEL-TEST',
|
||||||
|
hotels: [
|
||||||
|
{
|
||||||
|
hotel_id: 'HOTEL-TEST',
|
||||||
|
hotel_name: '测试酒店',
|
||||||
|
time_zone: 'Asia/Bangkok',
|
||||||
|
default_hotel: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hotel_id: 'HOTEL-BKK',
|
||||||
|
hotel_name: '曼谷酒店',
|
||||||
|
time_zone: 'Asia/Bangkok',
|
||||||
|
default_hotel: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
permissions: ['HOTEL_SWITCH'],
|
||||||
|
menus: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function mountWithPlugins(component: object, initialPath = '/', stubs: Record<string, unknown> = {}) {
|
async function mountWithPlugins(component: object, initialPath = '/', stubs: Record<string, unknown> = {}) {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.applyLoginResult(createAuthPayload())
|
||||||
const i18n = createI18n({
|
const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: 'zh-CN',
|
locale: 'zh-CN',
|
||||||
@@ -260,7 +297,7 @@ async function mountWithPlugins(component: object, initialPath = '/', stubs: Rec
|
|||||||
|
|
||||||
return mount(component, {
|
return mount(component, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [i18n, router],
|
plugins: [pinia, i18n, router],
|
||||||
stubs: {
|
stubs: {
|
||||||
RouterLink: {
|
RouterLink: {
|
||||||
template: '<a><slot /></a>',
|
template: '<a><slot /></a>',
|
||||||
@@ -273,6 +310,8 @@ async function mountWithPlugins(component: object, initialPath = '/', stubs: Rec
|
|||||||
|
|
||||||
describe('reservation P0 views', () => {
|
describe('reservation P0 views', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
localStorage.clear()
|
||||||
vi.mocked(service.fetchReservationOrderDetail).mockReset()
|
vi.mocked(service.fetchReservationOrderDetail).mockReset()
|
||||||
vi.mocked(service.fetchReservationOrders).mockReset()
|
vi.mocked(service.fetchReservationOrders).mockReset()
|
||||||
vi.mocked(service.fetchReservationTaskList).mockReset()
|
vi.mocked(service.fetchReservationTaskList).mockReset()
|
||||||
@@ -366,6 +405,27 @@ describe('reservation P0 views', () => {
|
|||||||
expect(wrapper.text()).toContain('第 3 / 4 页')
|
expect(wrapper.text()).toContain('第 3 / 4 页')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reloads the order list when the selected hotel changes', async () => {
|
||||||
|
vi.mocked(service.fetchReservationOrders)
|
||||||
|
.mockResolvedValueOnce(createOrderListResult('GRP-001'))
|
||||||
|
.mockResolvedValueOnce(createOrderListResult('GRP-002'))
|
||||||
|
|
||||||
|
const wrapper = await mountWithPlugins(ReservationOrderListView)
|
||||||
|
await vi.dynamicImportSettled()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
authStore.setSelectedHotelId('HOTEL-BKK')
|
||||||
|
await vi.dynamicImportSettled()
|
||||||
|
|
||||||
|
expect(service.fetchReservationOrders).toHaveBeenCalledTimes(2)
|
||||||
|
expect(service.fetchReservationOrders).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
page_num: 1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(wrapper.text()).toContain('GRP-002')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders task list items returned by the backend', async () => {
|
it('renders task list items returned by the backend', async () => {
|
||||||
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createTaskListResult('GRP-001', 'Booking Request'))
|
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createTaskListResult('GRP-001', 'Booking Request'))
|
||||||
|
|
||||||
@@ -444,6 +504,48 @@ describe('reservation P0 views', () => {
|
|||||||
expect(wrapper.text()).toContain('第 2 / 3 页')
|
expect(wrapper.text()).toContain('第 2 / 3 页')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reloads the task list when the selected hotel changes', async () => {
|
||||||
|
vi.mocked(service.fetchReservationTaskList)
|
||||||
|
.mockResolvedValueOnce(createTaskListResult('GRP-001', 'Booking Request'))
|
||||||
|
.mockResolvedValueOnce(createTaskListResult('GRP-002', 'Booking Update'))
|
||||||
|
|
||||||
|
const wrapper = await mountWithPlugins(ReservationTaskListView)
|
||||||
|
await vi.dynamicImportSettled()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
authStore.setSelectedHotelId('HOTEL-BKK')
|
||||||
|
await vi.dynamicImportSettled()
|
||||||
|
|
||||||
|
expect(service.fetchReservationTaskList).toHaveBeenCalledTimes(2)
|
||||||
|
expect(service.fetchReservationTaskList).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
page_num: 1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(wrapper.text()).toContain('Booking Update')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads order detail when the selected hotel changes', async () => {
|
||||||
|
vi.mocked(service.fetchReservationOrderDetail)
|
||||||
|
.mockResolvedValueOnce(createOrderDetailResult('GRP-001'))
|
||||||
|
.mockResolvedValueOnce(createOrderDetailResult('GRP-002'))
|
||||||
|
|
||||||
|
const wrapper = await mountWithPlugins(ReservationOrderDetailView, '/reservation/orders/20001', {
|
||||||
|
ReservationTaskDetailPanel: {
|
||||||
|
template: '<section />',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await vi.dynamicImportSettled()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
authStore.setSelectedHotelId('HOTEL-BKK')
|
||||||
|
await vi.dynamicImportSettled()
|
||||||
|
|
||||||
|
expect(service.fetchReservationOrderDetail).toHaveBeenCalledTimes(2)
|
||||||
|
expect(service.fetchReservationOrderDetail).toHaveBeenLastCalledWith('20001')
|
||||||
|
expect(wrapper.text()).toContain('GRP-002')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the latest task list response when filters change quickly', async () => {
|
it('keeps the latest task list response when filters change quickly', async () => {
|
||||||
const firstRequest = createDeferred<Awaited<ReturnType<typeof service.fetchReservationTaskList>>>()
|
const firstRequest = createDeferred<Awaited<ReturnType<typeof service.fetchReservationTaskList>>>()
|
||||||
const secondRequest = createDeferred<Awaited<ReturnType<typeof service.fetchReservationTaskList>>>()
|
const secondRequest = createDeferred<Awaited<ReturnType<typeof service.fetchReservationTaskList>>>()
|
||||||
|
|||||||
@@ -133,11 +133,13 @@ import ReservationStatusBadge from '@/components/reservation/ReservationStatusBa
|
|||||||
import ReservationTaskDetailPanel from '@/components/reservation/ReservationTaskDetailPanel.vue'
|
import ReservationTaskDetailPanel from '@/components/reservation/ReservationTaskDetailPanel.vue'
|
||||||
import ReservationTaskQueue from '@/components/reservation/ReservationTaskQueue.vue'
|
import ReservationTaskQueue from '@/components/reservation/ReservationTaskQueue.vue'
|
||||||
import { fetchReservationOrderDetail } from '@/services/reservationService'
|
import { fetchReservationOrderDetail } from '@/services/reservationService'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import type { ReservationOrderDetailResult } from '@/types/reservation'
|
import type { ReservationOrderDetailResult } from '@/types/reservation'
|
||||||
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const detail = ref<ReservationOrderDetailResult | null>(null)
|
const detail = ref<ReservationOrderDetailResult | null>(null)
|
||||||
@@ -154,6 +156,16 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => authStore.selectedHotelId,
|
||||||
|
(nextHotelId, previousHotelId) => {
|
||||||
|
if (!nextHotelId || nextHotelId === previousHotelId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void loadOrder(orderId.value)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async function loadOrder(nextOrderId: string): Promise<void> {
|
async function loadOrder(nextOrderId: string): Promise<void> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
|||||||
@@ -199,10 +199,12 @@ import { useI18n } from 'vue-i18n'
|
|||||||
|
|
||||||
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
||||||
import { fetchReservationOrders } from '@/services/reservationService'
|
import { fetchReservationOrders } from '@/services/reservationService'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import type { ReservationOrderListFilters, ReservationOrderListItem, ReservationPageResult } from '@/types/reservation'
|
import type { ReservationOrderListFilters, ReservationOrderListItem, ReservationPageResult } from '@/types/reservation'
|
||||||
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const orders = ref<ReservationOrderListItem[]>([])
|
const orders = ref<ReservationOrderListItem[]>([])
|
||||||
@@ -228,6 +230,16 @@ watch(filters, () => {
|
|||||||
void loadOrders()
|
void loadOrders()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => authStore.selectedHotelId,
|
||||||
|
(nextHotelId, previousHotelId) => {
|
||||||
|
if (!nextHotelId || nextHotelId === previousHotelId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reloadOrdersForHotelChange()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async function loadOrders(): Promise<void> {
|
async function loadOrders(): Promise<void> {
|
||||||
const requestSequence = ++orderListRequestSequence
|
const requestSequence = ++orderListRequestSequence
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -266,6 +278,14 @@ function resetToFirstPage(): void {
|
|||||||
filters.page_num = 1
|
filters.page_num = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reloadOrdersForHotelChange(): void {
|
||||||
|
if (filters.page_num !== 1) {
|
||||||
|
filters.page_num = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void loadOrders()
|
||||||
|
}
|
||||||
|
|
||||||
function goToPage(page: number): void {
|
function goToPage(page: number): void {
|
||||||
filters.page_num = Math.min(Math.max(page, 1), totalPages.value)
|
filters.page_num = Math.min(Math.max(page, 1), totalPages.value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
|
|
||||||
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
||||||
import { fetchReservationTaskList } from '@/services/reservationService'
|
import { fetchReservationTaskList } from '@/services/reservationService'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import type { ReservationPageResult, ReservationTaskListFilters, ReservationTaskListItem } from '@/types/reservation'
|
import type { ReservationPageResult, ReservationTaskListFilters, ReservationTaskListItem } from '@/types/reservation'
|
||||||
import {
|
import {
|
||||||
formatReservationReadonlyReason,
|
formatReservationReadonlyReason,
|
||||||
@@ -266,6 +267,7 @@ import {
|
|||||||
} from '@/utils/reservationDisplay'
|
} from '@/utils/reservationDisplay'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const tasks = ref<ReservationTaskListItem[]>([])
|
const tasks = ref<ReservationTaskListItem[]>([])
|
||||||
@@ -293,6 +295,16 @@ watch(filters, () => {
|
|||||||
void loadTasks()
|
void loadTasks()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => authStore.selectedHotelId,
|
||||||
|
(nextHotelId, previousHotelId) => {
|
||||||
|
if (!nextHotelId || nextHotelId === previousHotelId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reloadTasksForHotelChange()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async function loadTasks(): Promise<void> {
|
async function loadTasks(): Promise<void> {
|
||||||
const requestSequence = ++taskListRequestSequence
|
const requestSequence = ++taskListRequestSequence
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -333,6 +345,14 @@ function resetToFirstPage(): void {
|
|||||||
filters.page_num = 1
|
filters.page_num = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reloadTasksForHotelChange(): void {
|
||||||
|
if (filters.page_num !== 1) {
|
||||||
|
filters.page_num = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void loadTasks()
|
||||||
|
}
|
||||||
|
|
||||||
function goToPage(page: number): void {
|
function goToPage(page: number): void {
|
||||||
filters.page_num = Math.min(Math.max(page, 1), totalPages.value)
|
filters.page_num = Math.min(Math.max(page, 1), totalPages.value)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user