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

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

No files matched your search

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