修复V4任务详情来源邮件展示

This commit is contained in:
andy
2026-07-21 10:29:03 +07:00
parent b44ab94113
commit c28130b3da
8 changed files with 488 additions and 35 deletions

View File

@@ -4,16 +4,16 @@
| --- | --- |
| 最近更新 | 2026-07-21 |
| 当前分支 | `feature/huangting` |
| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情 V4 总览、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口、V4 业务审计查询、停止旧任务双写、Debug EML V4 profile 对齐、Room Information 后端展示模型与前端业务化展示、Rooming List 确认自动 DEF 后端联动、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览文档口径、Rooming List 事项确认卡文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 |
| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`V4 后新业务主线只写 V4 order task / cards / source notificationDebug EML V4 smoke 默认复用实时 AgentBus V4 Open API subject避免误走历史 Debug V2/V3 profile。开发阶段不维护 V2/V3 旧任务兼容,测试数据可重建,生产迁移策略后续上线前单独设计。`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;旧 `open_task_count` / `next_processable_task_id` 仅作历史诊断兼容。Room Information 已完成后端稳定展示模型和前端业务化展示:`GET /api/reservation/order-tasks/{orderTaskId}``display_payload.room_information` 返回 New / Update / Cancel 的 `current_values``proposed_values``final_values``change_summary[]`,前端只消费该展示模型和 `fields[]`,不再从 Agent raw payload、`business_fields``target_order` 自行推导Nights、Breakfast 和 Group Booking Status 均以后端派生值为准;确认和复核写入稳定 `confirmed_payload_json.room_information.final_values`,不回写 Agent 原始 `target_order`、Adult、邮件正文或附件 URL。Rooming List 卡确认时已实现 Group 自动置 `DEF`:如同订单存在可更新的已确认 Room Information 快照,后端会覆盖其 `group_booking_status=DEF` 并写 `V4_ROOMING_LIST_AUTO_DEF` 审计如没有可更新投影Rooming List 确认仍成功,只写安全审计提示,不临时创建不完整 Room Information。已确认 Rate Code 下一阶段按 Reservation Account + `booking_type`GROUP / FIT过滤和校验不按全酒店 Rate Code 全量展示;已确认 Payment 卡展示付款凭证附件时,`attachment_ids[]` 第一版只读,前端只展示并确认卡片,不增删或替换附件集合,图片在卡片内显示缩略图并点击大图预览,非图片统一文件列表 + 下载,附件外链仍走 SourceMessage 原文权限链路;已确认 Rooming List 任务卡第一版只做事项确认不做名单解析、附件预览、Excel 生成或 PMS 导入,用户点击“确认卡片”表示已人工处理该 Rooming List 事项;已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、Rooming List 前端轻量卡展示、Payment 附件预览、Account 范围 Rate Code lookup、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 |
| 当前阶段 | M002 V4 入站、多卡模型、持久化基线、入站写入、查询接口、卡片确认、复核解阻、目录校验、订单详情 V4 总览、DB 目录、Lookup API、前端 lookup 接入、目录管理后台 CP1 前后端、订单列表 V4 继续处理入口 / open count 收口、V4 业务审计查询、停止旧任务双写、Debug EML V4 profile 对齐、Room Information 后端展示模型与前端业务化展示、V4 任务详情 smoke 修复、Rooming List 确认自动 DEF 后端联动、Account + booking type 过滤 Rate Code 文档口径、Payment 附件预览文档口径、Rooming List 事项确认卡文档口径,以及 V4 复核态卡片交互和字段白名单文档口径 |
| 当前重点 | M002 V4 已停止普通业务入站双写旧 `workflow_reservation_task`V4 后新业务主线只写 V4 order task / cards / source notificationDebug EML V4 smoke 默认复用实时 AgentBus V4 Open API subject避免误走历史 Debug V2/V3 profile。开发阶段不维护 V2/V3 旧任务兼容,测试数据可重建,生产迁移策略后续上线前单独设计。`GET /api/reservation/orders` 可返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示字段 `open_work_item_count`;旧 `open_task_count` / `next_processable_task_id` 仅作历史诊断兼容。Room Information 已完成后端稳定展示模型和前端业务化展示:`GET /api/reservation/order-tasks/{orderTaskId}``display_payload.room_information` 返回 New / Update / Cancel 的 `current_values``proposed_values``final_values``change_summary[]`,前端只消费该展示模型和 `fields[]`,不再从 Agent raw payload、`business_fields``target_order` 自行推导Nights、Breakfast 和 Group Booking Status 均以后端派生值为准;确认和复核写入稳定 `confirmed_payload_json.room_information.final_values`,不回写 Agent 原始 `target_order`、Adult、邮件正文或附件 URL;接口对前端暴露的 `fields[].write_target` 使用 `confirmed_payload` / `review_resolution.field_overrides` 这类安全语义不暴露内部列名。V4 任务详情 smoke 修复已完成:页面顺序固定为 Basic Information、业务卡、SourceMessage Display来源邮件卡位于页面底部只通过 SourceMessage conversation 接口定位当前触发邮件并默认折叠正文Basic Information 的展示和确认 payload 不再返回 Agent `target_order`。Rooming List 卡确认时已实现 Group 自动置 `DEF`:如同订单存在可更新的已确认 Room Information 快照,后端会覆盖其 `group_booking_status=DEF` 并写 `V4_ROOMING_LIST_AUTO_DEF` 审计;刷新任务详情时 `display_payload``confirmed_payload` 和订单总览均以 DEF 后的确认快照为准;如没有可更新投影Rooming List 确认仍成功,只写安全审计提示,不临时创建不完整 Room Information。已确认 Rate Code 下一阶段按 Reservation Account + `booking_type`GROUP / FIT过滤和校验不按全酒店 Rate Code 全量展示;已确认 Payment 卡展示付款凭证附件时,`attachment_ids[]` 第一版只读,前端只展示并确认卡片,不增删或替换附件集合,图片在卡片内显示缩略图并点击大图预览,非图片统一文件列表 + 下载,附件外链仍走 SourceMessage 原文权限链路;已确认 Rooming List 任务卡第一版只做事项确认不做名单解析、附件预览、Excel 生成或 PMS 导入,用户点击“确认卡片”表示已人工处理该 Rooming List 事项;已确认 `REVIEW_REQUIRED` 仍是原业务卡复核态,页面按钮统一叫“确认卡片”,复核态允许编辑当前卡 `fields[]` 白名单内业务字段,问题字段红字提示。后续可继续做测试机 V4 smoke 复测、Rooming List 前端轻量卡展示、Payment 附件预览、Account 范围 Rate Code lookup、真实 PMS / OPERA / OHIP 同步或 SuperAgent 目录供给方案。 |
## 1. 当前 Checkpoint
- 名称:`M002-V4-rooming-list-confirm-def-backend`
- 状态:Backend Done实现 V4 `ROOMING_LIST` 卡确认后 Group Booking Status 自动置 `DEF` 的后端联动
- 目标:普通确认或 `REVIEW_REQUIRED` 复核确认 Rooming List 卡时,如果同订单为 Group 且存在可更新的已确认 Room Information 快照,后端强制覆盖 `group_booking_status=DEF` 并保留审计Fit 不变更
- 边界:本 checkpoint 不改变 Room Information 查询响应结构;不解析名单、不预览附件、不生成 Excel、不导入 PMS、不调用 M010 Rooming List 工具;不推进 Payment 附件预览、Account + booking type Rate Code lookup、真实 PMS / OPERA / OHIP 或 M011 CP4
- 联调备注:前端确认 `ROOMING_LIST` 时只需按卡片状态调用普通 confirm 或 `review-resolution`;后端负责自动 DEF 和 `V4_ROOMING_LIST_AUTO_DEF` 审计。没有可更新 Room Information 投影时确认仍成功,不创建不完整 Room Information只写安全审计提示
- 名称:`M002-V4-task-detail-smoke-fixes-frontend`
- 状态:Frontend Done修复测试机 smoke 发现的 V4 任务详情页展示顺序、来源邮件正文和 UPDATE_BOOKING 对比展示问题
- 目标:V4 任务详情页展示顺序固定为 Basic Information、业务卡、SourceMessage Display来源邮件卡位于页面底部通过 SourceMessage conversation 接口定位当前触发该 order task 的邮件正文默认折叠并可展开UPDATE_BOOKING Room Information 明确展示当前值、建议值和最终值
- 边界:本 checkpoint 不做 Payment 附件预览、不做 Rate Code lookup、不改后端接口前端不展示 `ai_payload_json`、raw evidence、附件 URL 或未 sanitize 的 `html_body`
- 联调备注:若用户缺少 SourceMessage 原文权限或 conversation 接口失败来源邮件卡降级展示安全摘要和邮件会话入口SourceMessage Display 不再渲染后端返回的通用 `fields[]`,避免误透敏感字段
## 2. 当前优先级

View File

@@ -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;

View File

@@ -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;

View File

@@ -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',
},

View File

@@ -625,6 +625,12 @@ export default {
conversationCount: 'จำนวนอีเมลในเธรด',
attachments: 'ไฟล์แนบ',
excerpt: 'ข้อความบางส่วนของอีเมล',
body: 'เนื้อหาอีเมลปัจจุบัน',
bodyLoading: 'กำลังโหลดเนื้อหาอีเมลปัจจุบัน...',
bodyLoadFailed: 'ยังโหลดเนื้อหาอีเมลปัจจุบันไม่ได้ โปรดเปิดเธรดอีเมลเพื่อดู',
noBody: 'ไม่มีเนื้อหาอีเมลปัจจุบัน',
showFullBody: 'แสดงทั้งหมด',
collapseBody: 'ย่อเนื้อหา',
noExcerpt: 'ไม่มีข้อความบางส่วน',
noAttachments: 'ไม่มีไฟล์แนบ',
},

View File

@@ -625,6 +625,12 @@ export default {
conversationCount: '会话邮件数',
attachments: '附件',
excerpt: '邮件片段',
body: '当前邮件正文',
bodyLoading: '正在加载当前邮件正文...',
bodyLoadFailed: '当前邮件正文暂时无法加载,可进入邮件会话查看。',
noBody: '暂无当前邮件正文。',
showFullBody: '展开全文',
collapseBody: '收起正文',
noExcerpt: '暂无邮件片段',
noAttachments: '无附件',
},

View File

@@ -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

View File

@@ -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>