修复V4任务详情来源邮件展示
This commit is contained in:
@@ -36,11 +36,11 @@
|
||||
>
|
||||
<h3>{{ t('taskV4.roomInformation.currentValues') }}</h3>
|
||||
<dl
|
||||
v-if="scalarEntries(currentValues).length"
|
||||
v-if="scalarEntries(currentValues, { hideEditableBreakfast: false }).length"
|
||||
class="room-information__grid"
|
||||
>
|
||||
<template
|
||||
v-for="entry in scalarEntries(currentValues)"
|
||||
v-for="entry in scalarEntries(currentValues, { hideEditableBreakfast: false })"
|
||||
:key="entry.key"
|
||||
>
|
||||
<dt>{{ roomInformationFieldLabel(entry.key) }}</dt>
|
||||
@@ -78,6 +78,54 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="showProposedValues"
|
||||
class="room-information__panel room-information__panel--proposed"
|
||||
>
|
||||
<h3>{{ t('taskV4.roomInformation.proposedValues') }}</h3>
|
||||
<dl
|
||||
v-if="scalarEntries(proposedValues, { hideEditableBreakfast: false }).length"
|
||||
class="room-information__grid"
|
||||
>
|
||||
<template
|
||||
v-for="entry in scalarEntries(proposedValues, { hideEditableBreakfast: false })"
|
||||
:key="entry.key"
|
||||
>
|
||||
<dt>{{ roomInformationFieldLabel(entry.key) }}</dt>
|
||||
<dd>{{ entry.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<p
|
||||
v-else-if="!roomItems(proposedValues).length"
|
||||
class="room-information__empty"
|
||||
>
|
||||
{{ t('taskV4.roomInformation.emptyValues') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="roomItems(proposedValues).length"
|
||||
class="room-information__room-items"
|
||||
>
|
||||
<h4>{{ t('taskV4.roomInformation.roomItems') }}</h4>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('taskV4.roomInformation.roomTypeCode') }}</th>
|
||||
<th>{{ t('taskV4.roomInformation.roomCount') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(item, index) in roomItems(proposedValues)"
|
||||
:key="index"
|
||||
>
|
||||
<td>{{ formatRoomInformationValue('room_type_code', item.room_type_code, item) }}</td>
|
||||
<td>{{ formatRoomInformationValue('room_count', item.room_count, item) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="room-information__panel">
|
||||
<h3>{{ t('taskV4.roomInformation.finalValues') }}</h3>
|
||||
<div class="room-information__derived">
|
||||
@@ -210,6 +258,7 @@ const roomInformation = computed<ReservationV4RoomInformationDisplayModel | null
|
||||
const eventType = computed(() => roomInformation.value?.event_type ?? props.card.event_type ?? '')
|
||||
const bookingType = computed(() => roomInformation.value?.booking_type ?? '')
|
||||
const currentValues = computed(() => normalizeValues(roomInformation.value?.current_values))
|
||||
const proposedValues = computed(() => normalizeValues(roomInformation.value?.proposed_values))
|
||||
const finalValues = computed(() => normalizeValues(roomInformation.value?.final_values))
|
||||
const changeSummary = computed(() => roomInformation.value?.change_summary ?? [])
|
||||
const visibleChangeSummary = computed(() => changeSummary.value.filter((change) =>
|
||||
@@ -220,7 +269,12 @@ const visibleFields = computed(() => props.card.fields.filter((field) =>
|
||||
))
|
||||
const showChangeSummary = computed(() => eventType.value === 'UPDATE_BOOKING' && visibleChangeSummary.value.length > 0)
|
||||
const showCurrentValues = computed(() =>
|
||||
['CANCEL_BOOKING'].includes(eventType.value) && hasVisibleValues(currentValues.value),
|
||||
['UPDATE_BOOKING', 'CANCEL_BOOKING'].includes(eventType.value) &&
|
||||
hasVisibleValues(currentValues.value, { hideEditableBreakfast: false }),
|
||||
)
|
||||
const showProposedValues = computed(() =>
|
||||
eventType.value === 'UPDATE_BOOKING' &&
|
||||
hasVisibleValues(proposedValues.value, { hideEditableBreakfast: false }),
|
||||
)
|
||||
const showEditableFields = computed(() => eventType.value !== 'CANCEL_BOOKING' && visibleFields.value.length > 0)
|
||||
const showReadonlyBreakfast = computed(() =>
|
||||
@@ -241,11 +295,19 @@ const scalarFieldOrder = [
|
||||
'confirmation_number',
|
||||
]
|
||||
|
||||
function scalarEntries(values: ReservationV4RoomInformationValues): Array<{ key: string; value: string }> {
|
||||
function scalarEntries(
|
||||
values: ReservationV4RoomInformationValues,
|
||||
options: { hideEditableBreakfast?: boolean } = {},
|
||||
): Array<{ key: string; value: string }> {
|
||||
const hideEditableBreakfast = options.hideEditableBreakfast ?? true
|
||||
return scalarFieldOrder
|
||||
.filter((key) => key in values && hasValue(values[key]))
|
||||
.filter((key) => key !== 'group_booking_status_label')
|
||||
.filter((key) => key !== 'breakfast_included' || !hasEditableField('/room_information/final_values/breakfast_included'))
|
||||
.filter((key) =>
|
||||
key !== 'breakfast_included' ||
|
||||
!hideEditableBreakfast ||
|
||||
!hasEditableField('/room_information/final_values/breakfast_included'),
|
||||
)
|
||||
.map((key) => ({
|
||||
key,
|
||||
value: formatRoomInformationValue(key, values[key], values),
|
||||
@@ -279,8 +341,11 @@ function isVisibleRoomInformationChangeField(field: unknown): boolean {
|
||||
!key.includes('locator_value')
|
||||
}
|
||||
|
||||
function hasVisibleValues(values: ReservationV4RoomInformationValues): boolean {
|
||||
return scalarEntries(values).length > 0 || roomItems(values).length > 0
|
||||
function hasVisibleValues(
|
||||
values: ReservationV4RoomInformationValues,
|
||||
options: { hideEditableBreakfast?: boolean } = {},
|
||||
): boolean {
|
||||
return scalarEntries(values, options).length > 0 || roomItems(values).length > 0
|
||||
}
|
||||
|
||||
function normalizeValues(value: unknown): ReservationV4RoomInformationValues {
|
||||
@@ -391,6 +456,11 @@ const roomInformationLabelKeys: Record<string, string> = {
|
||||
background: linear-gradient(180deg, var(--th-color-white), var(--th-color-info-bg));
|
||||
}
|
||||
|
||||
.room-information__panel--proposed {
|
||||
border-color: color-mix(in srgb, var(--th-color-warning) 24%, var(--th-color-slate-200));
|
||||
background: color-mix(in srgb, var(--th-color-warning-bg) 42%, var(--th-color-white));
|
||||
}
|
||||
|
||||
.room-information__panel--changes ul {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
@@ -42,6 +42,51 @@
|
||||
<p>{{ sourceExcerpt }}</p>
|
||||
</div>
|
||||
|
||||
<div class="source-preview source-body">
|
||||
<h3>{{ t('taskV4.sourceMessage.body') }}</h3>
|
||||
<p
|
||||
v-if="bodyLoading"
|
||||
class="source-body__muted"
|
||||
>
|
||||
{{ t('taskV4.sourceMessage.bodyLoading') }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="bodyError"
|
||||
class="source-body__muted"
|
||||
>
|
||||
{{ t('taskV4.sourceMessage.bodyLoadFailed') }}
|
||||
</p>
|
||||
<template v-else-if="messageBodyText">
|
||||
<!-- eslint-disable vue/no-v-html -- only backend-sanitized html_body_sanitized is rendered; raw html_body is never used here. -->
|
||||
<div
|
||||
v-if="showExpandedHtmlBody"
|
||||
class="source-body__html"
|
||||
v-html="sanitizedHtmlBody"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<p
|
||||
v-else
|
||||
class="source-body__text"
|
||||
>
|
||||
{{ visibleMessageBodyText }}
|
||||
</p>
|
||||
<button
|
||||
v-if="canToggleBody"
|
||||
type="button"
|
||||
class="source-body__toggle"
|
||||
@click="bodyExpanded = !bodyExpanded"
|
||||
>
|
||||
{{ bodyExpanded ? t('taskV4.sourceMessage.collapseBody') : t('taskV4.sourceMessage.showFullBody') }}
|
||||
</button>
|
||||
</template>
|
||||
<p
|
||||
v-else
|
||||
class="source-body__muted"
|
||||
>
|
||||
{{ t('taskV4.sourceMessage.noBody') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="source-preview">
|
||||
<h3>{{ t('taskV4.sourceMessage.attachments') }}</h3>
|
||||
<ul
|
||||
@@ -65,13 +110,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ReservationV4TaskCardFieldRenderer
|
||||
v-if="sourceCard?.fields.length"
|
||||
:fields="sourceCard.fields"
|
||||
:model-value="fieldValues"
|
||||
:read-only="true"
|
||||
/>
|
||||
|
||||
<RouterLink
|
||||
v-if="showConversationLink && sourceMessageId"
|
||||
class="source-link"
|
||||
@@ -83,19 +121,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
||||
import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue'
|
||||
import { fetchSourceMessageConversation } from '@/services/reservationService'
|
||||
import type {
|
||||
ReservationRecord,
|
||||
SourceMessageConversationItem,
|
||||
ReservationV4SourceMessageSummary,
|
||||
ReservationV4TaskCardResult,
|
||||
} from '@/types/reservation'
|
||||
import { formatReservationDateTime } from '@/utils/reservationFormat'
|
||||
import { buildReservationV4InitialFieldValues } from '@/utils/reservationV4FieldRules'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
@@ -107,9 +145,14 @@ const props = withDefaults(defineProps<{
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const bodyPreviewLength = 360
|
||||
const currentMessage = ref<SourceMessageConversationItem | null>(null)
|
||||
const bodyLoading = ref(false)
|
||||
const bodyError = ref(false)
|
||||
const bodyExpanded = ref(false)
|
||||
let bodyRequestSequence = 0
|
||||
|
||||
const payload = computed(() => props.sourceCard?.display_payload ?? {})
|
||||
const fieldValues = computed(() => buildReservationV4InitialFieldValues(props.sourceCard?.fields ?? []))
|
||||
const sourceMessageId = computed(() =>
|
||||
props.sourceMessageSummary?.source_message_id ?? readString(payload.value, 'source_message_id'),
|
||||
)
|
||||
@@ -141,11 +184,78 @@ const sourceExcerpt = computed(() =>
|
||||
'summary',
|
||||
]) ?? t('taskV4.sourceMessage.noExcerpt'),
|
||||
)
|
||||
const attachments = computed(() => normalizeAttachments(readFirstDefined(payload.value, [
|
||||
const sourcePayloadAttachments = computed(() => normalizeAttachments(readFirstDefined(payload.value, [
|
||||
'attachments',
|
||||
'uploaded_media',
|
||||
'file_references',
|
||||
])))
|
||||
const currentMessageAttachments = computed(() => normalizeAttachments([
|
||||
...(currentMessage.value?.attachments ?? []),
|
||||
...(currentMessage.value?.inline_images ?? []),
|
||||
]))
|
||||
const attachments = computed(() =>
|
||||
sourcePayloadAttachments.value.length ? sourcePayloadAttachments.value : currentMessageAttachments.value,
|
||||
)
|
||||
const sanitizedHtmlBody = computed(() => {
|
||||
const message = currentMessage.value
|
||||
const htmlBody = message?.html_body_sanitized?.trim()
|
||||
if (!htmlBody || (message?.html_render_mode && message.html_render_mode !== 'SANITIZED_HTML')) {
|
||||
return ''
|
||||
}
|
||||
return htmlBody
|
||||
})
|
||||
const messageBodyText = computed(() => {
|
||||
if (sanitizedHtmlBody.value) {
|
||||
return htmlToText(sanitizedHtmlBody.value)
|
||||
}
|
||||
return compactBodyText(currentMessage.value?.text_body ?? '')
|
||||
})
|
||||
const canToggleBody = computed(() => messageBodyText.value.length > bodyPreviewLength)
|
||||
const visibleMessageBodyText = computed(() => {
|
||||
if (!canToggleBody.value || bodyExpanded.value) {
|
||||
return messageBodyText.value
|
||||
}
|
||||
return `${messageBodyText.value.slice(0, bodyPreviewLength).trimEnd()}...`
|
||||
})
|
||||
const showExpandedHtmlBody = computed(() =>
|
||||
bodyExpanded.value && Boolean(sanitizedHtmlBody.value),
|
||||
)
|
||||
|
||||
watch(sourceMessageId, (nextSourceMessageId) => {
|
||||
void loadCurrentSourceMessage(nextSourceMessageId)
|
||||
}, { immediate: true })
|
||||
|
||||
async function loadCurrentSourceMessage(nextSourceMessageId: string | null) {
|
||||
bodyRequestSequence += 1
|
||||
const requestSequence = bodyRequestSequence
|
||||
currentMessage.value = null
|
||||
bodyError.value = false
|
||||
bodyExpanded.value = false
|
||||
|
||||
if (!nextSourceMessageId) {
|
||||
bodyLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
bodyLoading.value = true
|
||||
try {
|
||||
const conversation = await fetchSourceMessageConversation(nextSourceMessageId)
|
||||
if (requestSequence !== bodyRequestSequence) {
|
||||
return
|
||||
}
|
||||
currentMessage.value = conversation.messages.find((message) =>
|
||||
String(message.id) === String(nextSourceMessageId),
|
||||
) ?? null
|
||||
} catch {
|
||||
if (requestSequence === bodyRequestSequence) {
|
||||
bodyError.value = true
|
||||
}
|
||||
} finally {
|
||||
if (requestSequence === bodyRequestSequence) {
|
||||
bodyLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AttachmentSummary {
|
||||
key: string
|
||||
@@ -175,10 +285,10 @@ function normalizeAttachments(value: unknown): AttachmentSummary[] {
|
||||
function toAttachmentSummary(value: unknown, index: number): AttachmentSummary {
|
||||
const record = readRecord(value)
|
||||
const name = record
|
||||
? safeAttachmentName(readFirstString(record, ['name', 'file_name', 'filename', 'display_name', 'title']))
|
||||
? safeAttachmentName(readFirstString(record, ['name', 'file_name', 'fileName', 'filename', 'display_name', 'title']))
|
||||
: safeAttachmentName(String(value))
|
||||
const type = record ? readFirstString(record, ['content_type', 'mime_type', 'media_type', 'file_type']) : null
|
||||
const size = record ? readNumber(record, 'size_bytes') ?? readNumber(record, 'size') : null
|
||||
const type = record ? readFirstString(record, ['content_type', 'contentType', 'mime_type', 'media_type', 'mediaType', 'file_type']) : null
|
||||
const size = record ? readNumber(record, 'size_bytes') ?? readNumber(record, 'sizeBytes') ?? readNumber(record, 'size') : null
|
||||
return {
|
||||
key: `${name}-${index}`,
|
||||
name,
|
||||
@@ -261,6 +371,19 @@ function safeAttachmentName(value: string | null): string {
|
||||
function isUnsafeAttachmentText(value: string): boolean {
|
||||
return /^(https?:\/\/|oss:\/\/|s3:\/\/)/i.test(value.trim())
|
||||
}
|
||||
|
||||
function htmlToText(value: string): string {
|
||||
if (typeof document !== 'undefined') {
|
||||
const element = document.createElement('div')
|
||||
element.innerHTML = value
|
||||
return compactBodyText(element.textContent ?? '')
|
||||
}
|
||||
return compactBodyText(value.replace(/<[^>]*>/g, ' '))
|
||||
}
|
||||
|
||||
function compactBodyText(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -307,6 +430,34 @@ function isUnsafeAttachmentText(value: string): boolean {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.source-body__text,
|
||||
.source-body__html {
|
||||
max-width: 100%;
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.source-body__html :deep(*) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.source-body__muted {
|
||||
color: var(--th-color-slate-500);
|
||||
}
|
||||
|
||||
.source-body__toggle {
|
||||
justify-self: start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--th-color-blue-600);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.attachment-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
@@ -625,6 +625,12 @@ export default {
|
||||
conversationCount: 'Conversation messages',
|
||||
attachments: 'Attachments',
|
||||
excerpt: 'Email excerpt',
|
||||
body: 'Current email body',
|
||||
bodyLoading: 'Loading current email body...',
|
||||
bodyLoadFailed: 'Current email body cannot be loaded right now. Open the email conversation to view it.',
|
||||
noBody: 'No current email body.',
|
||||
showFullBody: 'Show full body',
|
||||
collapseBody: 'Collapse body',
|
||||
noExcerpt: 'No email excerpt',
|
||||
noAttachments: 'No attachments',
|
||||
},
|
||||
|
||||
@@ -625,6 +625,12 @@ export default {
|
||||
conversationCount: 'จำนวนอีเมลในเธรด',
|
||||
attachments: 'ไฟล์แนบ',
|
||||
excerpt: 'ข้อความบางส่วนของอีเมล',
|
||||
body: 'เนื้อหาอีเมลปัจจุบัน',
|
||||
bodyLoading: 'กำลังโหลดเนื้อหาอีเมลปัจจุบัน...',
|
||||
bodyLoadFailed: 'ยังโหลดเนื้อหาอีเมลปัจจุบันไม่ได้ โปรดเปิดเธรดอีเมลเพื่อดู',
|
||||
noBody: 'ไม่มีเนื้อหาอีเมลปัจจุบัน',
|
||||
showFullBody: 'แสดงทั้งหมด',
|
||||
collapseBody: 'ย่อเนื้อหา',
|
||||
noExcerpt: 'ไม่มีข้อความบางส่วน',
|
||||
noAttachments: 'ไม่มีไฟล์แนบ',
|
||||
},
|
||||
|
||||
@@ -625,6 +625,12 @@ export default {
|
||||
conversationCount: '会话邮件数',
|
||||
attachments: '附件',
|
||||
excerpt: '邮件片段',
|
||||
body: '当前邮件正文',
|
||||
bodyLoading: '正在加载当前邮件正文...',
|
||||
bodyLoadFailed: '当前邮件正文暂时无法加载,可进入邮件会话查看。',
|
||||
noBody: '暂无当前邮件正文。',
|
||||
showFullBody: '展开全文',
|
||||
collapseBody: '收起正文',
|
||||
noExcerpt: '暂无邮件片段',
|
||||
noAttachments: '无附件',
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ReservationV4OrderTaskDetailResult,
|
||||
ReservationV4SourceNotificationDetailResult,
|
||||
ReservationV4TaskCardResult,
|
||||
SourceMessageConversationResult,
|
||||
} from '@/types/reservation'
|
||||
import ReservationV4OrderTaskDetailView from '@/views/reservation/ReservationV4OrderTaskDetailView.vue'
|
||||
import ReservationV4SourceNotificationDetailView from '@/views/reservation/ReservationV4SourceNotificationDetailView.vue'
|
||||
@@ -27,6 +28,7 @@ vi.mock('@/services/reservationService', async (importOriginal) => {
|
||||
fetchReservationV4RateCodeLookups: vi.fn(),
|
||||
fetchReservationV4RoomTypeLookups: vi.fn(),
|
||||
fetchReservationV4SourceNotificationDetail: vi.fn(),
|
||||
fetchSourceMessageConversation: vi.fn(),
|
||||
resolveReservationV4OrderTaskCardReview: vi.fn(),
|
||||
}
|
||||
})
|
||||
@@ -43,7 +45,9 @@ describe('reservation V4 pages', () => {
|
||||
vi.mocked(service.fetchReservationV4RateCodeLookups).mockReset()
|
||||
vi.mocked(service.fetchReservationV4RoomTypeLookups).mockReset()
|
||||
vi.mocked(service.fetchReservationV4SourceNotificationDetail).mockReset()
|
||||
vi.mocked(service.fetchSourceMessageConversation).mockReset()
|
||||
vi.mocked(service.resolveReservationV4OrderTaskCardReview).mockReset()
|
||||
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult())
|
||||
mockCatalogLookups()
|
||||
})
|
||||
|
||||
@@ -150,6 +154,139 @@ describe('reservation V4 pages', () => {
|
||||
expect(wrapper.text()).not.toContain('oss://bucket')
|
||||
})
|
||||
|
||||
it('renders V4 source message display at the bottom with only the current email body collapsed', async () => {
|
||||
const detail = createOrderTaskDetail()
|
||||
detail.source_message_card!.fields = [
|
||||
createField('/source_message/ai_payload_json', {
|
||||
display_name: 'AI payload',
|
||||
value: 'AI_PAYLOAD_SHOULD_NOT_RENDER',
|
||||
}),
|
||||
createField('/source_message/raw_evidence', {
|
||||
display_name: 'Raw evidence',
|
||||
value: 'RAW_EVIDENCE_SHOULD_NOT_RENDER',
|
||||
}),
|
||||
createField('/source_message/attachment_url', {
|
||||
display_name: 'Attachment URL',
|
||||
value: 'https://oss.example/private/source-field.pdf',
|
||||
}),
|
||||
]
|
||||
const currentBody = `Current trigger email body opening. ${'Current booking detail. '.repeat(24)}CURRENT_BODY_TAIL`
|
||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({
|
||||
sourceMessageId: '30001',
|
||||
currentBody,
|
||||
}))
|
||||
|
||||
const wrapper = await mountWithPlugins(
|
||||
ReservationV4OrderTaskDetailView,
|
||||
'/reservation/order-tasks/9001',
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(service.fetchSourceMessageConversation).toHaveBeenCalledWith('30001')
|
||||
const pageText = wrapper.text()
|
||||
const basicIndex = pageText.indexOf(zhCN.taskV4.basicInformationCard)
|
||||
const businessIndex = pageText.indexOf(zhCN.taskV4.businessCards)
|
||||
const sourceIndex = pageText.indexOf(zhCN.taskV4.sourceMessageCard)
|
||||
expect(basicIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(businessIndex).toBeGreaterThan(basicIndex)
|
||||
expect(sourceIndex).toBeGreaterThan(businessIndex)
|
||||
const sections = wrapper.findAll('.th-section')
|
||||
expect(sections[sections.length - 1]?.text()).toContain(zhCN.taskV4.sourceMessageCard)
|
||||
expect(pageText).toContain('Current trigger email body opening.')
|
||||
expect(pageText).not.toContain('OLDER_MESSAGE_BODY')
|
||||
expect(pageText).not.toContain('REPLY_MESSAGE_BODY')
|
||||
expect(pageText).not.toContain('CURRENT_BODY_TAIL')
|
||||
expect(pageText).not.toContain('AI_PAYLOAD_SHOULD_NOT_RENDER')
|
||||
expect(pageText).not.toContain('RAW_EVIDENCE_SHOULD_NOT_RENDER')
|
||||
expect(pageText).not.toContain('https://oss.example/private/source-field.pdf')
|
||||
|
||||
const expandButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes(zhCN.taskV4.sourceMessage.showFullBody))
|
||||
expect(expandButton).toBeTruthy()
|
||||
await expandButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('CURRENT_BODY_TAIL')
|
||||
expect(wrapper.text()).toContain(zhCN.taskV4.sourceMessage.collapseBody)
|
||||
})
|
||||
|
||||
it('falls back to source summary when current source message body cannot be loaded', async () => {
|
||||
const detail = createOrderTaskDetail()
|
||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||
vi.mocked(service.fetchSourceMessageConversation).mockRejectedValue(new ApiError('Forbidden', 403, {
|
||||
code: 'SOURCE_MESSAGE_ORIGINAL_READ_FORBIDDEN',
|
||||
message: 'Forbidden',
|
||||
}))
|
||||
|
||||
const wrapper = await mountWithPlugins(
|
||||
ReservationV4OrderTaskDetailView,
|
||||
'/reservation/order-tasks/9001',
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Please book one twin room.')
|
||||
expect(wrapper.text()).toContain(zhCN.taskV4.sourceMessage.bodyLoadFailed)
|
||||
expect(wrapper.text()).toContain(zhCN.task.viewConversation)
|
||||
})
|
||||
|
||||
it('renders only sanitized source message html and never raw html body', async () => {
|
||||
const detail = createOrderTaskDetail()
|
||||
const sanitizedHtml = `<p>Sanitized current email opening. ${'Sanitized detail. '.repeat(24)}SANITIZED_HTML_TAIL</p>`
|
||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({
|
||||
sourceMessageId: '30001',
|
||||
currentBody: '',
|
||||
sanitizedHtml,
|
||||
rawHtml: '<script>RAW_HTML_SHOULD_NOT_RENDER</script>',
|
||||
htmlRenderMode: 'SANITIZED_HTML',
|
||||
}))
|
||||
|
||||
const wrapper = await mountWithPlugins(
|
||||
ReservationV4OrderTaskDetailView,
|
||||
'/reservation/order-tasks/9001',
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Sanitized current email opening.')
|
||||
expect(wrapper.text()).not.toContain('SANITIZED_HTML_TAIL')
|
||||
expect(wrapper.text()).not.toContain('RAW_HTML_SHOULD_NOT_RENDER')
|
||||
|
||||
const expandButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes(zhCN.taskV4.sourceMessage.showFullBody))
|
||||
expect(expandButton).toBeTruthy()
|
||||
await expandButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('SANITIZED_HTML_TAIL')
|
||||
expect(wrapper.html()).toContain('<p>Sanitized current email opening.')
|
||||
expect(wrapper.html()).not.toContain('RAW_HTML_SHOULD_NOT_RENDER')
|
||||
})
|
||||
|
||||
it('does not render sanitized html when backend marks the email body as text only', async () => {
|
||||
const detail = createOrderTaskDetail()
|
||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({
|
||||
sourceMessageId: '30001',
|
||||
currentBody: 'Plain text fallback body.',
|
||||
sanitizedHtml: '<p>SANITIZED_HTML_SHOULD_NOT_RENDER</p>',
|
||||
rawHtml: '<p>RAW_HTML_SHOULD_NOT_RENDER</p>',
|
||||
htmlRenderMode: 'TEXT_ONLY',
|
||||
}))
|
||||
|
||||
const wrapper = await mountWithPlugins(
|
||||
ReservationV4OrderTaskDetailView,
|
||||
'/reservation/order-tasks/9001',
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Plain text fallback body.')
|
||||
expect(wrapper.text()).not.toContain('SANITIZED_HTML_SHOULD_NOT_RENDER')
|
||||
expect(wrapper.text()).not.toContain('RAW_HTML_SHOULD_NOT_RENDER')
|
||||
})
|
||||
|
||||
it('renders New Booking Room Information as a business form and confirms stable final values', async () => {
|
||||
const detail = createOrderTaskDetail({
|
||||
businessEventType: 'NEW_BOOKING',
|
||||
@@ -393,6 +530,8 @@ describe('reservation V4 pages', () => {
|
||||
|
||||
const roomCard = wrapper.find('[data-testid="room-information-card"]')
|
||||
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.changeSummary)
|
||||
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.currentValues)
|
||||
expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.proposedValues)
|
||||
expect(roomCard.text()).toContain('2026-07-26')
|
||||
expect(roomCard.text()).toContain('2026-07-27')
|
||||
expect(roomCard.text()).toContain('3')
|
||||
@@ -986,6 +1125,81 @@ function createOrderTaskDetail(options: {
|
||||
}
|
||||
}
|
||||
|
||||
function createSourceMessageConversationResult(options: {
|
||||
sourceMessageId?: string
|
||||
currentBody?: string
|
||||
sanitizedHtml?: string | null
|
||||
rawHtml?: string | null
|
||||
htmlRenderMode?: SourceMessageConversationResult['messages'][number]['html_render_mode']
|
||||
} = {}): SourceMessageConversationResult {
|
||||
const sourceMessageId = options.sourceMessageId ?? '30001'
|
||||
const currentBody = options.currentBody ?? 'Current trigger email body.'
|
||||
return {
|
||||
conversation: {
|
||||
hotel_id: 'HOTEL-TEST',
|
||||
channel: 'EMAIL',
|
||||
external_conversation_id: 'thread-1',
|
||||
subject: 'Booking Request',
|
||||
message_count: 3,
|
||||
first_received_at: '2026-07-08T02:00:00Z',
|
||||
last_received_at: '2026-07-08T04:00:00Z',
|
||||
},
|
||||
messages: [
|
||||
createConversationMessage({
|
||||
id: '29999',
|
||||
text_body: 'OLDER_MESSAGE_BODY',
|
||||
received_at: '2026-07-08T02:00:00Z',
|
||||
}),
|
||||
createConversationMessage({
|
||||
id: sourceMessageId,
|
||||
text_body: currentBody,
|
||||
html_body: options.rawHtml ?? null,
|
||||
html_body_sanitized: options.sanitizedHtml ?? null,
|
||||
html_render_mode: options.htmlRenderMode,
|
||||
received_at: '2026-07-08T03:00:00Z',
|
||||
attachments: [
|
||||
{
|
||||
mediaType: 'ATTACHMENT',
|
||||
fileName: 'current-message.pdf',
|
||||
contentType: 'application/pdf',
|
||||
sizeBytes: 2048,
|
||||
externalUrl: 'https://oss.example/private/current-message.pdf',
|
||||
externalMediaId: 'media-current',
|
||||
},
|
||||
],
|
||||
}),
|
||||
createConversationMessage({
|
||||
id: '30002',
|
||||
text_body: 'REPLY_MESSAGE_BODY',
|
||||
received_at: '2026-07-08T04:00:00Z',
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function createConversationMessage(
|
||||
overrides: Partial<SourceMessageConversationResult['messages'][number]> & { id: string },
|
||||
): SourceMessageConversationResult['messages'][number] {
|
||||
return {
|
||||
id: overrides.id,
|
||||
external_message_id: overrides.external_message_id ?? `external-${overrides.id}`,
|
||||
external_conversation_id: overrides.external_conversation_id ?? 'thread-1',
|
||||
sender_summary: overrides.sender_summary ?? 'guest@example.test',
|
||||
subject: overrides.subject ?? 'Booking Request',
|
||||
received_at: overrides.received_at ?? '2026-07-08T03:00:00Z',
|
||||
source_sent_at: overrides.source_sent_at ?? null,
|
||||
text_body: overrides.text_body ?? null,
|
||||
html_body: overrides.html_body ?? null,
|
||||
html_body_sanitized: overrides.html_body_sanitized ?? null,
|
||||
html_sanitize_required: overrides.html_sanitize_required ?? false,
|
||||
html_render_mode: overrides.html_render_mode ?? (overrides.html_body_sanitized ? 'SANITIZED_HTML' : 'TEXT_ONLY'),
|
||||
inline_images: overrides.inline_images ?? [],
|
||||
attachments: overrides.attachments ?? [],
|
||||
related_orders: overrides.related_orders ?? [],
|
||||
related_tasks: overrides.related_tasks ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
function createRoomInformationDisplayPayload(overrides: {
|
||||
event_type?: string
|
||||
booking_type?: string
|
||||
|
||||
@@ -78,12 +78,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ReservationV4SourceMessageCard
|
||||
:title="t('taskV4.sourceMessageCard')"
|
||||
:source-message-summary="detail.source_message_summary"
|
||||
:source-card="detail.source_message_card"
|
||||
/>
|
||||
|
||||
<TaskCardSection
|
||||
v-if="detail.basic_information_card"
|
||||
:card="detail.basic_information_card"
|
||||
@@ -177,6 +171,12 @@
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ReservationV4SourceMessageCard
|
||||
:title="t('taskV4.sourceMessageCard')"
|
||||
:source-message-summary="detail.source_message_summary"
|
||||
:source-card="detail.source_message_card"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user