diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 75b2405..f480261 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -15,9 +15,10 @@ export const router = createRouter({ routes: [ { path: '/', - redirect: () => { - const authStore = useAuthStore() - return authStore.firstMenuPath ?? '/reservation/orders' + name: 'home', + component: { + 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)) { return { name: 'unauthorized', diff --git a/client/src/services/debugEmlService.ts b/client/src/services/debugEmlService.ts index 2ef757b..0f568d8 100644 --- a/client/src/services/debugEmlService.ts +++ b/client/src/services/debugEmlService.ts @@ -5,6 +5,7 @@ import type { DebugEmlUploadInput, } from '@/types/debugEml' import { getStoredAccessToken } from '@/services/authSession' +import { isAuthSessionInvalidPayload, notifyUnauthorized } from '@/services/httpClient' const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '' const debugEmlEndpoint = '/api/system/debug/eml-superagent-runs' @@ -42,6 +43,9 @@ export async function uploadDebugEmlSuperAgentRun( const payload = await readResponsePayload(response) if (!response.ok) { + if (isAuthSessionInvalidPayload(payload)) { + notifyUnauthorized() + } const errorPayload = isDebugEmlErrorResponse(payload) ? payload : {} throw new DebugEmlUploadError( errorPayload.message || `Request failed with status ${response.status}`, diff --git a/client/src/services/httpClient.ts b/client/src/services/httpClient.ts index c1f1942..0b6c16a 100644 --- a/client/src/services/httpClient.ts +++ b/client/src/services/httpClient.ts @@ -39,6 +39,18 @@ export function setUnauthorizedHandler(handler: UnauthorizedHandler): void { 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(path: string, init: RequestInit): Promise { const response = await fetch(`${apiBaseUrl}${path}`, { ...init, @@ -48,7 +60,7 @@ async function requestJson(path: string, init: RequestInit): Promise { const payload = await readResponsePayload(response) if (!response.ok) { if (isAuthUnauthorized(response.status, payload)) { - unauthorizedHandler?.() + notifyUnauthorized() } throw new ApiError(`Request failed with status ${response.status}`, response.status, payload) } @@ -93,7 +105,7 @@ function mergeHeaders(target: Record, source?: HeadersInit): voi } function isAuthUnauthorized(status: number, payload: unknown): boolean { - if (authUnauthorizedErrorCodes.has(errorCodeFromPayload(payload))) { + if (isAuthSessionInvalidPayload(payload)) { return true } return status === 401 && errorCodeFromPayload(payload) !== 'AUTH_INVALID_CREDENTIALS' diff --git a/client/src/stores/authStore.ts b/client/src/stores/authStore.ts index 0eafbfa..5c143ce 100644 --- a/client/src/stores/authStore.ts +++ b/client/src/stores/authStore.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { fetchCurrentAuth, loginAuth, logoutAuth } from '@/services/authService' import { clearStoredAccessToken, getStoredAccessToken, setStoredAccessToken } from '@/services/authSession' +import { isAuthUnauthorizedError } from '@/services/httpClient' import type { AuthHotelResult, AuthLoginRequest, @@ -78,9 +79,14 @@ export const useAuthStore = defineStore('auth', { this.applyAuthContext(result) this.restoreAttempted = true return true - } catch { - this.clearAuth() - this.restoreAttempted = true + } catch (error) { + if (isAuthUnauthorizedError(error)) { + this.clearAuth() + this.restoreAttempted = true + } else { + this.accessToken = token + this.restoreAttempted = false + } return false } finally { this.restoring = false diff --git a/client/src/tests/authStore.spec.ts b/client/src/tests/authStore.spec.ts index fb28723..7d05792 100644 --- a/client/src/tests/authStore.spec.ts +++ b/client/src/tests/authStore.spec.ts @@ -1,6 +1,7 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiError } from '@/services/httpClient' import { useAuthStore } from '@/stores/authStore' vi.mock('@/services/authService', async (importOriginal) => { @@ -96,8 +97,13 @@ describe('authStore', () => { expect(store.selectedHotelId).toBe('HOTEL-TEST') }) - it('clears token and user context when restore fails', async () => { - vi.mocked(authService.fetchCurrentAuth).mockRejectedValue(new Error('AUTH_SESSION_INVALID')) + it('clears token and user context when the auth session is invalid', async () => { + vi.mocked(authService.fetchCurrentAuth).mockRejectedValue( + new ApiError('登录已失效。', 401, { + error_code: 'AUTH_SESSION_INVALID', + message: '登录已失效。', + }), + ) sessionStorage.setItem('th_hotel_access_token', 'expired-token') const store = useAuthStore() @@ -109,6 +115,25 @@ describe('authStore', () => { 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 () => { vi.mocked(authService.loginAuth).mockResolvedValue(createAuthPayload()) vi.mocked(authService.logoutAuth).mockRejectedValue(new Error('network down')) diff --git a/client/src/tests/debugEmlService.spec.ts b/client/src/tests/debugEmlService.spec.ts index f95222c..6a4f4bf 100644 --- a/client/src/tests/debugEmlService.spec.ts +++ b/client/src/tests/debugEmlService.spec.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { DebugEmlUploadError, uploadDebugEmlSuperAgentRun } from '@/services/debugEmlService' +import { setUnauthorizedHandler } from '@/services/httpClient' const jsonHeaders = { headers: { @@ -21,6 +22,7 @@ function mockJsonResponse(payload: unknown, status = 201): Response { describe('debugEmlService', () => { beforeEach(() => { vi.restoreAllMocks() + setUnauthorizedHandler(null) }) 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 () => { + const unauthorizedHandler = vi.fn() + setUnauthorizedHandler(unauthorizedHandler) vi.spyOn(globalThis, 'fetch').mockResolvedValue( mockJsonResponse( { @@ -98,5 +102,34 @@ describe('debugEmlService', () => { errorCode: 'DEBUG_UPLOAD_KEY_INVALID', message: 'Debug 上传访问口令缺失或错误。', } satisfies Partial) + 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) + + expect(unauthorizedHandler).toHaveBeenCalledOnce() }) }) diff --git a/client/src/tests/reservationRouter.spec.ts b/client/src/tests/reservationRouter.spec.ts index cd6ed10..8709feb 100644 --- a/client/src/tests/reservationRouter.spec.ts +++ b/client/src/tests/reservationRouter.spec.ts @@ -85,4 +85,32 @@ describe('reservation router', () => { 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) + }) }) diff --git a/client/src/tests/reservationViews.spec.ts b/client/src/tests/reservationViews.spec.ts index 1eb613b..6034150 100644 --- a/client/src/tests/reservationViews.spec.ts +++ b/client/src/tests/reservationViews.spec.ts @@ -1,4 +1,5 @@ import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' import { createMemoryHistory, createRouter } from 'vue-router' @@ -8,6 +9,7 @@ import ReservationOrderDetailView from '@/views/reservation/ReservationOrderDeta import ReservationOrderListView from '@/views/reservation/ReservationOrderListView.vue' import ReservationSourceMessageConversationView from '@/views/reservation/ReservationSourceMessageConversationView.vue' import ReservationTaskListView from '@/views/reservation/ReservationTaskListView.vue' +import { useAuthStore } from '@/stores/authStore' import type { SourceMessageConversationResult } from '@/types/reservation' vi.mock('@/services/reservationService', async (importOriginal) => { @@ -91,18 +93,18 @@ function createTaskListResult( } } -function createOrderDetailResult() { +function createOrderDetailResult(displayName = 'GRP-001') { return { order: { - order_id: '20001', + order_id: displayName === 'GRP-002' ? '20002' : '20001', hotel_id: 'HOTEL-TEST', order_status: 'ACTIVE', temporary_order_no: null, confirmation_number: null, - group_code: 'GRP-001', + group_code: displayName, block_code: null, allotment_code: null, - display_name: 'GRP-001', + display_name: displayName, created_at: '2026-07-08T03:00:00Z', updated_at: '2026-07-08T03:10:00Z', }, @@ -238,7 +240,42 @@ function createDeferred() { 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 = {}) { + const pinia = createPinia() + setActivePinia(pinia) + const authStore = useAuthStore() + authStore.applyLoginResult(createAuthPayload()) const i18n = createI18n({ legacy: false, locale: 'zh-CN', @@ -260,7 +297,7 @@ async function mountWithPlugins(component: object, initialPath = '/', stubs: Rec return mount(component, { global: { - plugins: [i18n, router], + plugins: [pinia, i18n, router], stubs: { RouterLink: { template: '', @@ -273,6 +310,8 @@ async function mountWithPlugins(component: object, initialPath = '/', stubs: Rec describe('reservation P0 views', () => { beforeEach(() => { + sessionStorage.clear() + localStorage.clear() vi.mocked(service.fetchReservationOrderDetail).mockReset() vi.mocked(service.fetchReservationOrders).mockReset() vi.mocked(service.fetchReservationTaskList).mockReset() @@ -366,6 +405,27 @@ describe('reservation P0 views', () => { 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 () => { vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createTaskListResult('GRP-001', 'Booking Request')) @@ -444,6 +504,48 @@ describe('reservation P0 views', () => { 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: '
', + }, + }) + 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 () => { const firstRequest = createDeferred>>() const secondRequest = createDeferred>>() diff --git a/client/src/views/reservation/ReservationOrderDetailView.vue b/client/src/views/reservation/ReservationOrderDetailView.vue index f3c4cc6..81e0366 100644 --- a/client/src/views/reservation/ReservationOrderDetailView.vue +++ b/client/src/views/reservation/ReservationOrderDetailView.vue @@ -133,11 +133,13 @@ import ReservationStatusBadge from '@/components/reservation/ReservationStatusBa import ReservationTaskDetailPanel from '@/components/reservation/ReservationTaskDetailPanel.vue' import ReservationTaskQueue from '@/components/reservation/ReservationTaskQueue.vue' import { fetchReservationOrderDetail } from '@/services/reservationService' +import { useAuthStore } from '@/stores/authStore' import type { ReservationOrderDetailResult } from '@/types/reservation' import { formatReservationDateTime } from '@/utils/reservationFormat' const route = useRoute() const { t } = useI18n() +const authStore = useAuthStore() const loading = ref(false) const errorMessage = ref('') const detail = ref(null) @@ -154,6 +156,16 @@ watch( { immediate: true }, ) +watch( + () => authStore.selectedHotelId, + (nextHotelId, previousHotelId) => { + if (!nextHotelId || nextHotelId === previousHotelId) { + return + } + void loadOrder(orderId.value) + }, +) + async function loadOrder(nextOrderId: string): Promise { loading.value = true errorMessage.value = '' diff --git a/client/src/views/reservation/ReservationOrderListView.vue b/client/src/views/reservation/ReservationOrderListView.vue index 6746aa3..c11162f 100644 --- a/client/src/views/reservation/ReservationOrderListView.vue +++ b/client/src/views/reservation/ReservationOrderListView.vue @@ -199,10 +199,12 @@ import { useI18n } from 'vue-i18n' import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue' import { fetchReservationOrders } from '@/services/reservationService' +import { useAuthStore } from '@/stores/authStore' import type { ReservationOrderListFilters, ReservationOrderListItem, ReservationPageResult } from '@/types/reservation' import { formatReservationDateTime } from '@/utils/reservationFormat' const { t } = useI18n() +const authStore = useAuthStore() const loading = ref(false) const errorMessage = ref('') const orders = ref([]) @@ -228,6 +230,16 @@ watch(filters, () => { void loadOrders() }) +watch( + () => authStore.selectedHotelId, + (nextHotelId, previousHotelId) => { + if (!nextHotelId || nextHotelId === previousHotelId) { + return + } + reloadOrdersForHotelChange() + }, +) + async function loadOrders(): Promise { const requestSequence = ++orderListRequestSequence loading.value = true @@ -266,6 +278,14 @@ function resetToFirstPage(): void { filters.page_num = 1 } +function reloadOrdersForHotelChange(): void { + if (filters.page_num !== 1) { + filters.page_num = 1 + return + } + void loadOrders() +} + function goToPage(page: number): void { filters.page_num = Math.min(Math.max(page, 1), totalPages.value) } diff --git a/client/src/views/reservation/ReservationTaskListView.vue b/client/src/views/reservation/ReservationTaskListView.vue index ab03a1d..31bc756 100644 --- a/client/src/views/reservation/ReservationTaskListView.vue +++ b/client/src/views/reservation/ReservationTaskListView.vue @@ -258,6 +258,7 @@ import { useI18n } from 'vue-i18n' import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue' import { fetchReservationTaskList } from '@/services/reservationService' +import { useAuthStore } from '@/stores/authStore' import type { ReservationPageResult, ReservationTaskListFilters, ReservationTaskListItem } from '@/types/reservation' import { formatReservationReadonlyReason, @@ -266,6 +267,7 @@ import { } from '@/utils/reservationDisplay' const { t } = useI18n() +const authStore = useAuthStore() const loading = ref(false) const errorMessage = ref('') const tasks = ref([]) @@ -293,6 +295,16 @@ watch(filters, () => { void loadTasks() }) +watch( + () => authStore.selectedHotelId, + (nextHotelId, previousHotelId) => { + if (!nextHotelId || nextHotelId === previousHotelId) { + return + } + reloadTasksForHotelChange() + }, +) + async function loadTasks(): Promise { const requestSequence = ++taskListRequestSequence loading.value = true @@ -333,6 +345,14 @@ function resetToFirstPage(): void { filters.page_num = 1 } +function reloadTasksForHotelChange(): void { + if (filters.page_num !== 1) { + filters.page_num = 1 + return + } + void loadTasks() +} + function goToPage(page: number): void { filters.page_num = Math.min(Math.max(page, 1), totalPages.value) }