修复V4任务详情来源邮件展示
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user