提交一次全量代码

This commit is contained in:
andy
2026-07-09 11:59:34 +08:00
parent 855396e553
commit 9fa608f602
58 changed files with 8439 additions and 116 deletions

View File

@@ -32,9 +32,10 @@ describe('ReservationStatusBadge', () => {
expect(wrapper.classes()).toContain('status-badge--info')
})
it('falls back to the raw code for unknown status', () => {
it('falls back to a localized unknown status label', () => {
const wrapper = mountWithI18n('UNKNOWN_STATUS')
expect(wrapper.text()).toContain('UNKNOWN_STATUS')
expect(wrapper.text()).toContain('未知状态')
expect(wrapper.text()).not.toContain('UNKNOWN_STATUS')
})
})

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import enUS from '@/i18n/locales/en-US'
import thTH from '@/i18n/locales/th-TH'
import zhCN from '@/i18n/locales/zh-CN'
describe('reservation i18n locales', () => {
it('keeps Chinese, English, and Thai locale keys aligned', () => {
const zhKeys = collectKeyPaths(zhCN)
expect(collectKeyPaths(enUS)).toEqual(zhKeys)
expect(collectKeyPaths(thTH)).toEqual(zhKeys)
})
it('includes Thai labels for the P0 reservation navigation', () => {
expect(thTH.nav.orders).toBe('รายการออเดอร์')
expect(thTH.nav.taskQueue).toBe('คิวงาน')
expect(thTH.taskList.viewConversation).toBe('ดูเธรดอีเมล')
})
})
function collectKeyPaths(value: unknown, prefix = ''): string[] {
if (!isRecord(value)) {
return [prefix]
}
return Object.keys(value)
.sort()
.flatMap((key) => collectKeyPaths(value[key], prefix ? `${prefix}.${key}` : key))
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

View File

@@ -0,0 +1,70 @@
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { createMemoryHistory, createRouter } from 'vue-router'
import { describe, expect, it, vi } from 'vitest'
import ReservationAppShell from '@/layouts/ReservationAppShell.vue'
import enUS from '@/i18n/locales/en-US'
import thTH from '@/i18n/locales/th-TH'
import zhCN from '@/i18n/locales/zh-CN'
vi.mock('@/services/healthService', () => ({
fetchBackendHealth: vi.fn().mockResolvedValue({
status: 'UP',
service: 'test',
}),
}))
async function mountShell() {
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN,
'en-US': enUS,
'th-TH': thTH,
},
})
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/reservation/orders',
component: { template: '<div />' },
meta: { titleKey: 'nav.orders' },
},
{
path: '/reservation/tasks',
component: { template: '<div />' },
meta: { titleKey: 'nav.taskQueue' },
},
],
})
await router.push('/reservation/orders')
await router.isReady()
return mount(ReservationAppShell, {
global: {
plugins: [i18n, router, createPinia()],
},
slots: {
default: '<section />',
},
})
}
describe('ReservationAppShell', () => {
it('switches between Chinese, English, and Thai labels', async () => {
const wrapper = await mountShell()
expect(wrapper.text()).toContain('订单列表')
await wrapper.findAll('button').find((button) => button.text() === 'ไทย')?.trigger('click')
expect(wrapper.text()).toContain('รายการออเดอร์')
await wrapper.findAll('button').find((button) => button.text() === 'EN')?.trigger('click')
expect(wrapper.text()).toContain('Order list')
})
})

View File

@@ -0,0 +1,33 @@
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import zhCN from '@/i18n/locales/zh-CN'
import {
formatReservationActorType,
formatReservationReadonlyReason,
formatReservationTaskTypeSummary,
} from '@/utils/reservationDisplay'
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
messages: {
'zh-CN': zhCN,
},
})
describe('reservationDisplay', () => {
it('formats known task type and subtype codes without exposing duplicated raw codes', () => {
expect(formatReservationTaskTypeSummary(i18n.global.t, 'NEW_BOOKING', 'NEW_BOOKING')).toBe('新预订')
expect(formatReservationTaskTypeSummary(i18n.global.t, 'UPDATE_BOOKING', 'RATE_CHANGE')).toBe('修改预订 / 价格调整')
})
it('formats readonly reason and actor codes with user-facing labels', () => {
expect(formatReservationReadonlyReason(i18n.global.t, 'PREVIOUS_TASK_NOT_FINISHED')).toBe('前序任务未完成')
expect(formatReservationActorType(i18n.global.t, 'USER')).toBe('人工用户')
})
it('uses localized fallback labels for unknown stable codes', () => {
expect(formatReservationReadonlyReason(i18n.global.t, 'SOME_BACKEND_CODE')).toBe('暂不可处理')
})
})

View File

@@ -63,12 +63,13 @@ describe('reservationService real API mode', () => {
const result = await fetchReservationTaskList({
task_status: 'PENDING_CONFIRM',
order_status: 'ACTIVE',
page_num: 1,
page_size: 20,
})
expect(fetchMock).toHaveBeenCalledWith(
'/api/reservation/tasks?hotel_id=HOTEL-TEST&task_status=PENDING_CONFIRM&page_num=1&page_size=20',
'/api/reservation/tasks?hotel_id=HOTEL-TEST&task_status=PENDING_CONFIRM&order_status=ACTIVE&page_num=1&page_size=20',
expect.objectContaining({ method: 'GET' }),
)
expect(result.items).toHaveLength(1)
@@ -204,7 +205,9 @@ describe('reservationService real API mode', () => {
source_sent_at: '2026-07-08T01:58:00Z',
text_body: '完整邮件正文',
html_body: '<p>完整邮件正文</p>',
html_body_sanitized: '<p>完整邮件正文</p>',
html_sanitize_required: true,
html_render_mode: 'SANITIZED_HTML',
inline_images: [],
attachments: [],
related_orders: [

View File

@@ -259,13 +259,15 @@ describe('ReservationTaskDetailPanel', () => {
vi.mocked(service.saveReservationTaskDraft).mockResolvedValue(createMutationResult())
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('TASK_LOADED')
expect(wrapper.text()).toContain('任务读取')
await findButton(wrapper, '保存草稿').trigger('click')
await flushPromises()
expect(service.fetchReservationTaskAudits).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('DRAFT_SAVED')
expect(wrapper.text()).toContain('草稿保存')
expect(wrapper.text()).not.toContain('任务读取')
expect(wrapper.text()).not.toContain('DRAFT_SAVED')
expect(wrapper.text()).not.toContain('TASK_LOADED')
})

View File

@@ -80,6 +80,7 @@ describe('ReservationTaskQueue', () => {
])
expect(wrapper.text()).toContain('不可处理')
expect(wrapper.text()).toContain('PREVIOUS_TASK_NOT_FINISHED')
expect(wrapper.text()).toContain('前序任务未完成')
expect(wrapper.text()).not.toContain('PREVIOUS_TASK_NOT_FINISHED')
})
})

View File

@@ -8,6 +8,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 type { SourceMessageConversationResult } from '@/types/reservation'
vi.mock('@/services/reservationService', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/services/reservationService')>()
@@ -148,8 +149,8 @@ function createOrderDetailResult() {
}
function createSourceMessageConversationResult(
overrides: Partial<ReturnType<typeof createSourceMessageConversationResultBase>> = {},
) {
overrides: Partial<SourceMessageConversationResult> = {},
): SourceMessageConversationResult {
const result = createSourceMessageConversationResultBase()
return {
...result,
@@ -157,7 +158,7 @@ function createSourceMessageConversationResult(
}
}
function createSourceMessageConversationResultBase() {
function createSourceMessageConversationResultBase(): SourceMessageConversationResult {
return {
conversation: {
hotel_id: 'HOTEL-TEST',
@@ -178,8 +179,10 @@ function createSourceMessageConversationResultBase() {
received_at: '2026-07-08T02:00:00Z',
source_sent_at: '2026-07-08T01:58:00Z',
text_body: '完整邮件正文',
html_body: '<strong>不应直接渲染</strong>',
html_body: '<script>alert("raw")</script><strong onclick="alert(1)">原始邮件正文</strong>',
html_body_sanitized: '<strong>清洗后邮件正文</strong>',
html_sanitize_required: true,
html_render_mode: 'SANITIZED_HTML',
inline_images: [],
attachments: [
{
@@ -289,7 +292,9 @@ describe('reservation P0 views', () => {
expect(wrapper.text()).toContain('Booking Request')
expect(wrapper.text()).toContain('guest@example.test')
expect(wrapper.text()).toContain('2')
expect(wrapper.text()).toContain('前序任务未完成')
expect(wrapper.text()).toContain('查看邮件会话')
expect(wrapper.text()).not.toContain('PREVIOUS_TASK_NOT_FINISHED')
expect(wrapper.text()).not.toContain('来源邮件摘要字段待后端补充')
})
@@ -346,8 +351,10 @@ describe('reservation P0 views', () => {
expect(wrapper.text()).toContain('GRP-001')
expect(wrapper.text()).toContain('Booking Request')
expect(wrapper.text()).toContain('新预订')
expect(wrapper.text()).toContain('查看邮件会话')
expect(wrapper.text()).not.toContain('接口待补')
expect(wrapper.text()).not.toContain('NEW_BOOKING / NEW_BOOKING')
})
it('filters and paginates task list with supported backend fields', async () => {
@@ -355,17 +362,20 @@ describe('reservation P0 views', () => {
.mockResolvedValueOnce(createTaskListResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))
.mockResolvedValueOnce(createTaskListResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))
.mockResolvedValueOnce(createTaskListResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))
.mockResolvedValueOnce(createTaskListResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))
.mockResolvedValueOnce(createTaskListResult('GRP-002', 'Booking Update', { page_num: 2, page_size: 20, total: 45 }))
const wrapper = await mountWithPlugins(ReservationTaskListView)
await vi.dynamicImportSettled()
const selects = wrapper.findAll('select')
expect(wrapper.text()).toContain('订单状态筛选待后端接入')
expect(selects[2]!.attributes('disabled')).toBeDefined()
expect(wrapper.text()).not.toContain('订单状态筛选待后端接入')
expect(selects[2]!.attributes('disabled')).toBeUndefined()
await selects[1]!.setValue('READY')
await vi.dynamicImportSettled()
await selects[2]!.setValue('ENDED')
await vi.dynamicImportSettled()
await selects[3]!.setValue('true')
await vi.dynamicImportSettled()
@@ -373,10 +383,10 @@ describe('reservation P0 views', () => {
const filteredTaskRequest = taskListCalls[taskListCalls.length - 1]?.[0] ?? {}
expect(filteredTaskRequest).toMatchObject({
task_status: 'READY',
order_status: 'ENDED',
queue_participation: true,
page_num: 1,
})
expect(filteredTaskRequest).not.toHaveProperty('order_status')
await wrapper.findAll('button').find((button) => button.text().includes('下一页'))?.trigger('click')
await vi.dynamicImportSettled()
@@ -384,6 +394,7 @@ describe('reservation P0 views', () => {
expect(service.fetchReservationTaskList).toHaveBeenLastCalledWith(
expect.objectContaining({
task_status: 'READY',
order_status: 'ENDED',
queue_participation: true,
page_num: 2,
page_size: 20,
@@ -436,12 +447,34 @@ describe('reservation P0 views', () => {
expect(service.fetchSourceMessageConversation).toHaveBeenCalledWith('30001')
expect(wrapper.text()).toContain('Booking Request')
expect(wrapper.text()).toContain('不应直接渲染')
expect(wrapper.text()).toContain('清洗后邮件正文')
expect(wrapper.text()).toContain('rooming-list.xlsx')
expect(wrapper.find('.message-body').html()).toContain('<strong>不应直接渲染</strong>')
expect(wrapper.find('.message-body').html()).toContain('<strong>清洗后邮件正文</strong>')
expect(wrapper.find('.message-body').html()).not.toContain('<script>')
expect(wrapper.find('.message-body').html()).not.toContain('onclick')
expect(wrapper.text()).not.toContain('原始邮件正文')
expect(wrapper.text()).not.toContain('接口待接入')
})
it('falls back to text when sanitized html is unavailable', async () => {
const conversationResult = createSourceMessageConversationResult()
conversationResult.messages[0]!.html_body = '<strong>原始 HTML 不上屏</strong>'
conversationResult.messages[0]!.html_body_sanitized = null
conversationResult.messages[0]!.html_render_mode = 'TEXT_ONLY'
conversationResult.messages[0]!.text_body = '纯文本回退正文'
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(conversationResult)
const wrapper = await mountWithPlugins(
ReservationSourceMessageConversationView,
'/reservation/source-messages/30001/conversation',
)
await vi.dynamicImportSettled()
expect(wrapper.text()).toContain('纯文本回退正文')
expect(wrapper.text()).not.toContain('原始 HTML 不上屏')
expect(wrapper.find('.message-body--html').exists()).toBe(false)
})
it('does not expose unsafe attachment URLs as links', async () => {
const conversationResult = createSourceMessageConversationResult()
conversationResult.messages[0]!.attachments = [