实现V4 Payment附件预览前端
This commit is contained in:
@@ -0,0 +1,561 @@
|
||||
<template>
|
||||
<section
|
||||
v-if="attachments.length"
|
||||
class="payment-attachments"
|
||||
data-testid="payment-attachment-preview"
|
||||
>
|
||||
<div class="payment-attachments__header">
|
||||
<div>
|
||||
<h3>{{ t('taskV4.paymentAttachments.title') }}</h3>
|
||||
<p v-if="loading">
|
||||
{{ t('taskV4.paymentAttachments.loading') }}
|
||||
</p>
|
||||
<p v-else-if="loadErrorMessage">
|
||||
{{ loadErrorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="payment-attachments__grid">
|
||||
<li
|
||||
v-for="attachment in resolvedAttachments"
|
||||
:key="attachment.key"
|
||||
class="payment-attachment"
|
||||
>
|
||||
<button
|
||||
v-if="canPreview(attachment)"
|
||||
type="button"
|
||||
class="payment-attachment__thumbnail"
|
||||
data-testid="payment-attachment-thumbnail"
|
||||
:aria-label="t('taskV4.paymentAttachments.openPreview')"
|
||||
@click="selectedPreview = attachment"
|
||||
>
|
||||
<img
|
||||
:src="attachment.mediaUrl"
|
||||
:alt="attachment.fileName"
|
||||
>
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
class="payment-attachment__file"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ attachment.isImage ? 'IMG' : 'FILE' }}
|
||||
</div>
|
||||
|
||||
<div class="payment-attachment__body">
|
||||
<strong>{{ attachment.fileName }}</strong>
|
||||
<small>{{ attachmentMeta(attachment) }}</small>
|
||||
<small
|
||||
v-if="attachmentStatus(attachment)"
|
||||
class="payment-attachment__status"
|
||||
>
|
||||
{{ attachmentStatus(attachment) }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-if="canDownload(attachment)"
|
||||
class="payment-attachment__download"
|
||||
data-testid="payment-attachment-download"
|
||||
:href="attachment.mediaUrl"
|
||||
:download="attachment.fileName"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ t('taskV4.paymentAttachments.download') }}
|
||||
</a>
|
||||
<span
|
||||
v-else-if="!attachment.isImage"
|
||||
class="payment-attachment__disabled"
|
||||
>
|
||||
{{ t('taskV4.paymentAttachments.downloadUnavailable') }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
v-if="selectedPreview"
|
||||
class="payment-lightbox"
|
||||
data-testid="payment-attachment-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="selectedPreview = null"
|
||||
>
|
||||
<div class="payment-lightbox__panel">
|
||||
<button
|
||||
type="button"
|
||||
class="payment-lightbox__close"
|
||||
:aria-label="t('taskV4.paymentAttachments.closePreview')"
|
||||
@click="selectedPreview = null"
|
||||
>
|
||||
{{ t('taskV4.paymentAttachments.closePreview') }}
|
||||
</button>
|
||||
<img
|
||||
:src="selectedPreview.mediaUrl"
|
||||
:alt="selectedPreview.fileName"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { fetchSourceMessageConversation } from '@/services/reservationService'
|
||||
import type {
|
||||
ReservationRecord,
|
||||
ReservationV4PaymentAttachmentSummary,
|
||||
ReservationV4TaskCardResult,
|
||||
SourceMessageOriginalMedia,
|
||||
} from '@/types/reservation'
|
||||
|
||||
type PaymentAttachmentItem = {
|
||||
key: string
|
||||
attachmentId: string
|
||||
externalMediaId: string
|
||||
fileName: string
|
||||
contentType: string
|
||||
sizeBytes: number | null
|
||||
isImage: boolean
|
||||
previewAvailable: boolean
|
||||
downloadAvailable: boolean
|
||||
unavailableReason: string
|
||||
}
|
||||
|
||||
type ResolvedPaymentAttachment = PaymentAttachmentItem & {
|
||||
media: SourceMessageOriginalMedia | null
|
||||
mediaUrl: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
card: ReservationV4TaskCardResult
|
||||
sourceMessageId?: string | null
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const currentMessageMedia = ref<SourceMessageOriginalMedia[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref(false)
|
||||
const selectedPreview = ref<ResolvedPaymentAttachment | null>(null)
|
||||
let requestSequence = 0
|
||||
|
||||
const attachments = computed(() => normalizePaymentAttachments(props.card.display_payload?.payment_attachments))
|
||||
const attachmentSignature = computed(() =>
|
||||
attachments.value
|
||||
.map((attachment) => `${attachment.externalMediaId}:${attachment.attachmentId}:${attachment.fileName}`)
|
||||
.join('|'),
|
||||
)
|
||||
const loadErrorMessage = computed(() => {
|
||||
if (!attachments.value.length) {
|
||||
return ''
|
||||
}
|
||||
if (!props.sourceMessageId) {
|
||||
return t('taskV4.paymentAttachments.missingSourceMessage')
|
||||
}
|
||||
return loadError.value ? t('taskV4.paymentAttachments.loadFailed') : ''
|
||||
})
|
||||
const resolvedAttachments = computed<ResolvedPaymentAttachment[]>(() =>
|
||||
attachments.value.map((attachment) => {
|
||||
const media = matchCurrentMessageMedia(attachment)
|
||||
return {
|
||||
...attachment,
|
||||
media,
|
||||
mediaUrl: media ? safeMediaUrl(media) : '',
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => props.sourceMessageId, attachmentSignature],
|
||||
() => {
|
||||
void loadCurrentMessageMedia()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function loadCurrentMessageMedia(): Promise<void> {
|
||||
const currentRequest = ++requestSequence
|
||||
currentMessageMedia.value = []
|
||||
loadError.value = false
|
||||
selectedPreview.value = null
|
||||
|
||||
if (!props.sourceMessageId || !attachments.value.length) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const conversation = await fetchSourceMessageConversation(props.sourceMessageId)
|
||||
if (currentRequest !== requestSequence) {
|
||||
return
|
||||
}
|
||||
const currentMessage = conversation.messages.find((message) =>
|
||||
String(message.id) === String(props.sourceMessageId),
|
||||
)
|
||||
currentMessageMedia.value = currentMessage
|
||||
? [...currentMessage.attachments, ...currentMessage.inline_images]
|
||||
: []
|
||||
} catch {
|
||||
if (currentRequest === requestSequence) {
|
||||
loadError.value = true
|
||||
}
|
||||
} finally {
|
||||
if (currentRequest === requestSequence) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePaymentAttachments(value: unknown): PaymentAttachmentItem[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value
|
||||
.map((item, index) => normalizePaymentAttachment(item, index))
|
||||
.filter((item): item is PaymentAttachmentItem => Boolean(item))
|
||||
}
|
||||
|
||||
function normalizePaymentAttachment(value: unknown, index: number): PaymentAttachmentItem | null {
|
||||
const record = readRecord(value) as ReservationV4PaymentAttachmentSummary | null
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
|
||||
const attachmentId = readFirstString(record, ['attachment_id', 'attachmentId', 'id'])
|
||||
const externalMediaId = readFirstString(record, ['external_media_id', 'externalMediaId'])
|
||||
const fileName = safeFileName(readFirstString(record, ['file_name', 'fileName', 'name', 'display_name']))
|
||||
const contentType = readFirstString(record, ['content_type', 'contentType', 'mime_type']) ?? ''
|
||||
const sizeBytes = readNumber(record, 'size_bytes') ?? readNumber(record, 'sizeBytes') ?? readNumber(record, 'size')
|
||||
const isImage = readBoolean(record, 'is_image') ?? contentType.toLowerCase().startsWith('image/')
|
||||
|
||||
return {
|
||||
key: externalMediaId || attachmentId || `${fileName}-${index}`,
|
||||
attachmentId,
|
||||
externalMediaId,
|
||||
fileName,
|
||||
contentType,
|
||||
sizeBytes,
|
||||
isImage,
|
||||
previewAvailable: readBoolean(record, 'preview_available') ?? true,
|
||||
downloadAvailable: readBoolean(record, 'download_available') ?? true,
|
||||
unavailableReason: readFirstString(record, ['unavailable_reason_code', 'unavailableReasonCode']) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function matchCurrentMessageMedia(attachment: PaymentAttachmentItem): SourceMessageOriginalMedia | null {
|
||||
if (attachment.externalMediaId) {
|
||||
const matchedByExternalMediaId = currentMessageMedia.value.find((media) =>
|
||||
mediaExternalMediaId(media) === attachment.externalMediaId,
|
||||
)
|
||||
if (matchedByExternalMediaId) {
|
||||
return matchedByExternalMediaId
|
||||
}
|
||||
}
|
||||
if (attachment.attachmentId) {
|
||||
return currentMessageMedia.value.find((media) =>
|
||||
mediaAttachmentIds(media).includes(attachment.attachmentId),
|
||||
) ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function canPreview(attachment: ResolvedPaymentAttachment): boolean {
|
||||
return attachment.isImage &&
|
||||
attachment.previewAvailable &&
|
||||
Boolean(attachment.mediaUrl) &&
|
||||
!loadError.value
|
||||
}
|
||||
|
||||
function canDownload(attachment: ResolvedPaymentAttachment): boolean {
|
||||
return !attachment.isImage &&
|
||||
attachment.downloadAvailable &&
|
||||
Boolean(attachment.mediaUrl) &&
|
||||
!loadError.value
|
||||
}
|
||||
|
||||
function attachmentStatus(attachment: ResolvedPaymentAttachment): string {
|
||||
if (loadErrorMessage.value) {
|
||||
return loadErrorMessage.value
|
||||
}
|
||||
if (!attachment.media) {
|
||||
return t('taskV4.paymentAttachments.noMatchedMedia')
|
||||
}
|
||||
if (attachment.unavailableReason) {
|
||||
return t('taskV4.paymentAttachments.unavailableReason', { reason: attachment.unavailableReason })
|
||||
}
|
||||
if (attachment.isImage && !attachment.previewAvailable) {
|
||||
return t('taskV4.paymentAttachments.previewUnavailable')
|
||||
}
|
||||
if (!attachment.isImage && !attachment.downloadAvailable) {
|
||||
return t('taskV4.paymentAttachments.downloadUnavailable')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function attachmentMeta(attachment: PaymentAttachmentItem): string {
|
||||
const type = attachment.contentType || (attachment.isImage ? 'image' : 'file')
|
||||
const size = formatFileSize(attachment.sizeBytes)
|
||||
return size
|
||||
? t('taskV4.paymentAttachments.fileMeta', { type, size })
|
||||
: type
|
||||
}
|
||||
|
||||
function mediaExternalMediaId(media: SourceMessageOriginalMedia): string {
|
||||
return media.externalMediaId ?? readFirstString(media as unknown as ReservationRecord, ['external_media_id']) ?? ''
|
||||
}
|
||||
|
||||
function mediaAttachmentIds(media: SourceMessageOriginalMedia): string[] {
|
||||
const record = media as unknown as ReservationRecord
|
||||
return [
|
||||
readFirstString(record, ['attachment_id', 'attachmentId', 'id']),
|
||||
mediaExternalMediaId(media),
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
function safeMediaUrl(media: SourceMessageOriginalMedia): string {
|
||||
return media.externalUrl ?? readFirstString(media as unknown as ReservationRecord, ['external_url', 'url']) ?? ''
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): ReservationRecord | null {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
? value as ReservationRecord
|
||||
: null
|
||||
}
|
||||
|
||||
function readFirstString(record: ReservationRecord, keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = record[key]
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function readNumber(record: ReservationRecord, key: string): number | null {
|
||||
const value = record[key]
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string' && Number.isFinite(Number(value))) {
|
||||
return Number(value)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function readBoolean(record: ReservationRecord, key: string): boolean | null {
|
||||
const value = record[key]
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalizedValue = value.trim().toLowerCase()
|
||||
if (normalizedValue === 'true') {
|
||||
return true
|
||||
}
|
||||
if (normalizedValue === 'false') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function safeFileName(value: string): string {
|
||||
return value || t('conversation.unnamedAttachment')
|
||||
}
|
||||
|
||||
function formatFileSize(size: number | null): string {
|
||||
if (!size) {
|
||||
return ''
|
||||
}
|
||||
if (size < 1024) {
|
||||
return `${size} B`
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${Math.round(size / 1024)} KB`
|
||||
}
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.payment-attachments {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0 20px 18px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-slate-50);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.payment-attachments__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.payment-attachments__header h3,
|
||||
.payment-attachments__header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.payment-attachments__header h3 {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.payment-attachments__header p {
|
||||
margin-top: 4px;
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.payment-attachments__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.payment-attachment {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-white);
|
||||
list-style: none;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.payment-attachment__thumbnail,
|
||||
.payment-attachment__file {
|
||||
width: 88px;
|
||||
height: 64px;
|
||||
border: 1px solid var(--th-color-slate-200);
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-slate-50);
|
||||
}
|
||||
|
||||
.payment-attachment__thumbnail {
|
||||
cursor: zoom-in;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.payment-attachment__thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.payment-attachment__file {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.payment-attachment__body {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.payment-attachment__body strong {
|
||||
color: var(--th-color-slate-900);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.payment-attachment__body small,
|
||||
.payment-attachment__disabled {
|
||||
color: var(--th-color-slate-500);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.payment-attachment__status {
|
||||
color: var(--th-color-warning);
|
||||
}
|
||||
|
||||
.payment-attachment__download {
|
||||
color: var(--th-color-blue-600);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.payment-attachment__download:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.payment-lightbox {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(15 23 42 / 68%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.payment-lightbox__panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-width: min(960px, 92vw);
|
||||
max-height: 92vh;
|
||||
}
|
||||
|
||||
.payment-lightbox__panel img {
|
||||
max-width: 100%;
|
||||
max-height: 78vh;
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-white);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.payment-lightbox__close {
|
||||
justify-self: end;
|
||||
min-height: 36px;
|
||||
border: 0;
|
||||
border-radius: var(--th-radius-sm);
|
||||
background: var(--th-color-white);
|
||||
color: var(--th-color-slate-900);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.payment-attachment {
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.payment-attachment__thumbnail,
|
||||
.payment-attachment__file {
|
||||
width: 72px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.payment-attachment__download,
|
||||
.payment-attachment__disabled {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -45,17 +45,24 @@
|
||||
:hotel-id="hotelId"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
<ReservationV4TaskCardFieldRenderer
|
||||
v-else
|
||||
class="task-card-section__fields"
|
||||
:fields="card.fields"
|
||||
:model-value="modelValue"
|
||||
:read-only="readOnly"
|
||||
:editable-keys="editableKeys"
|
||||
:validation-errors="validationErrors"
|
||||
:hotel-id="hotelId"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
<template v-else>
|
||||
<ReservationV4PaymentAttachmentPreview
|
||||
v-if="isPaymentCard"
|
||||
:card="card"
|
||||
:source-message-id="sourceMessageId"
|
||||
/>
|
||||
<ReservationV4TaskCardFieldRenderer
|
||||
v-else
|
||||
class="task-card-section__fields"
|
||||
:fields="visibleFields"
|
||||
:model-value="modelValue"
|
||||
:read-only="readOnly"
|
||||
:editable-keys="editableKeys"
|
||||
:validation-errors="validationErrors"
|
||||
:hotel-id="hotelId"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<details
|
||||
v-if="!isRoomInformationCard && safePayloadRows.length"
|
||||
@@ -164,6 +171,7 @@ import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
|
||||
import ReservationV4PaymentAttachmentPreview from '@/components/reservation/ReservationV4PaymentAttachmentPreview.vue'
|
||||
import ReservationV4RoomInformationCard from '@/components/reservation/ReservationV4RoomInformationCard.vue'
|
||||
import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue'
|
||||
import type { ReservationRecord, ReservationV4TaskCardResult } from '@/types/reservation'
|
||||
@@ -187,6 +195,7 @@ const props = withDefaults(defineProps<{
|
||||
validationErrors?: Record<string, string>
|
||||
hotelId?: string
|
||||
orderTaskId?: string | null
|
||||
sourceMessageId?: string | null
|
||||
actionErrors?: string[]
|
||||
successMessage?: string
|
||||
reviewForm?: ReviewForm
|
||||
@@ -198,6 +207,7 @@ const props = withDefaults(defineProps<{
|
||||
validationErrors: () => ({}),
|
||||
hotelId: undefined,
|
||||
orderTaskId: null,
|
||||
sourceMessageId: null,
|
||||
actionErrors: () => [],
|
||||
successMessage: '',
|
||||
reviewForm: () => ({
|
||||
@@ -216,6 +226,12 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const isRoomInformationCard = computed(() => props.card.card_type === 'ROOM_INFORMATION')
|
||||
const isPaymentCard = computed(() => props.card.card_type === 'PAYMENT')
|
||||
const visibleFields = computed(() =>
|
||||
isPaymentCard.value
|
||||
? props.card.fields.filter((field) => !isPaymentAttachmentIdField(field))
|
||||
: props.card.fields,
|
||||
)
|
||||
|
||||
const availabilityReason = computed(() => {
|
||||
return props.card.availability.readonly_reason_message ??
|
||||
@@ -248,6 +264,11 @@ function updateReviewForm(field: keyof ReviewForm, event: Event): void {
|
||||
[field]: target.value,
|
||||
})
|
||||
}
|
||||
|
||||
function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields'][number]): boolean {
|
||||
const normalizedKey = `${field.field_pointer || ''} ${field.field_path || ''}`.toLowerCase()
|
||||
return normalizedKey.includes('attachment_ids')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -608,6 +608,21 @@ export default {
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
},
|
||||
paymentAttachments: {
|
||||
title: 'Payment voucher attachments',
|
||||
loading: 'Loading attachment preview access...',
|
||||
loadFailed: 'Attachment preview is unavailable right now. Check source email permission and retry.',
|
||||
empty: 'No payment voucher attachments.',
|
||||
openPreview: 'Open image preview',
|
||||
closePreview: 'Close preview',
|
||||
download: 'Download',
|
||||
previewUnavailable: 'Preview unavailable',
|
||||
downloadUnavailable: 'Download unavailable',
|
||||
noMatchedMedia: 'No matching attachment was found in the current email.',
|
||||
missingSourceMessage: 'Source email is missing, so preview and download are unavailable.',
|
||||
unavailableReason: 'Unavailable reason: {reason}',
|
||||
fileMeta: '{type} · {size}',
|
||||
},
|
||||
lookup: {
|
||||
loading: 'Loading catalog',
|
||||
empty: 'No options in the current catalog. A no-match search does not mean the catalog is uninitialized.',
|
||||
|
||||
@@ -608,6 +608,21 @@ export default {
|
||||
yes: 'ใช่',
|
||||
no: 'ไม่ใช่',
|
||||
},
|
||||
paymentAttachments: {
|
||||
title: 'ไฟล์แนบหลักฐานการชำระเงิน',
|
||||
loading: 'กำลังโหลดสิทธิ์ดูตัวอย่างไฟล์แนบ...',
|
||||
loadFailed: 'ยังดูตัวอย่างไฟล์แนบไม่ได้ โปรดตรวจสิทธิ์อีเมลต้นทางแล้วลองใหม่',
|
||||
empty: 'ไม่มีไฟล์แนบหลักฐานการชำระเงิน',
|
||||
openPreview: 'เปิดดูภาพขนาดใหญ่',
|
||||
closePreview: 'ปิดตัวอย่าง',
|
||||
download: 'ดาวน์โหลด',
|
||||
previewUnavailable: 'ดูตัวอย่างไม่ได้',
|
||||
downloadUnavailable: 'ดาวน์โหลดไม่ได้',
|
||||
noMatchedMedia: 'ไม่พบไฟล์แนบที่ตรงกันในอีเมลปัจจุบัน',
|
||||
missingSourceMessage: 'ไม่มีอีเมลต้นทาง จึงดูตัวอย่างหรือดาวน์โหลดไม่ได้',
|
||||
unavailableReason: 'เหตุผลที่ใช้ไม่ได้: {reason}',
|
||||
fileMeta: '{type} · {size}',
|
||||
},
|
||||
lookup: {
|
||||
loading: 'กำลังโหลดแค็ตตาล็อก',
|
||||
empty: 'ไม่มีตัวเลือกในแค็ตตาล็อกปัจจุบัน หากค้นหาไม่พบไม่ได้หมายความว่าแค็ตตาล็อกยังไม่เริ่มต้น',
|
||||
|
||||
@@ -608,6 +608,21 @@ export default {
|
||||
yes: '是',
|
||||
no: '否',
|
||||
},
|
||||
paymentAttachments: {
|
||||
title: '付款凭证附件',
|
||||
loading: '正在加载附件预览权限...',
|
||||
loadFailed: '附件预览暂不可用,可检查来源邮件权限后重试。',
|
||||
empty: '暂无付款凭证附件。',
|
||||
openPreview: '查看大图',
|
||||
closePreview: '关闭预览',
|
||||
download: '下载',
|
||||
previewUnavailable: '不可预览',
|
||||
downloadUnavailable: '不可下载',
|
||||
noMatchedMedia: '当前邮件中未找到匹配附件。',
|
||||
missingSourceMessage: '缺少来源邮件,暂不可预览或下载。',
|
||||
unavailableReason: '不可用原因:{reason}',
|
||||
fileMeta: '{type} · {size}',
|
||||
},
|
||||
lookup: {
|
||||
loading: '目录加载中',
|
||||
empty: '当前目录没有可选项;如果是搜索无结果,不代表目录未初始化。',
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
ReservationV4SourceNotificationDetailResult,
|
||||
ReservationV4TaskCardResult,
|
||||
SourceMessageConversationResult,
|
||||
SourceMessageOriginalMedia,
|
||||
} from '@/types/reservation'
|
||||
import ReservationV4OrderTaskDetailView from '@/views/reservation/ReservationV4OrderTaskDetailView.vue'
|
||||
import ReservationV4SourceNotificationDetailView from '@/views/reservation/ReservationV4SourceNotificationDetailView.vue'
|
||||
@@ -287,6 +288,104 @@ describe('reservation V4 pages', () => {
|
||||
expect(wrapper.text()).not.toContain('RAW_HTML_SHOULD_NOT_RENDER')
|
||||
})
|
||||
|
||||
it('renders Payment attachments with image preview and non-image download from current source message media', async () => {
|
||||
const detail = createOrderTaskDetail()
|
||||
usePaymentBusinessCard(detail)
|
||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({
|
||||
attachments: createPaymentConversationMedia(),
|
||||
}))
|
||||
|
||||
const wrapper = await mountWithPlugins(
|
||||
ReservationV4OrderTaskDetailView,
|
||||
'/reservation/order-tasks/9001',
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(service.fetchSourceMessageConversation).toHaveBeenCalledWith('30001')
|
||||
const attachmentPreview = wrapper.find('[data-testid="payment-attachment-preview"]')
|
||||
expect(attachmentPreview.exists()).toBe(true)
|
||||
expect(attachmentPreview.text()).toContain('voucher-image.jpg')
|
||||
expect(attachmentPreview.text()).toContain('voucher-document.pdf')
|
||||
expect(attachmentPreview.text()).toContain(zhCN.taskV4.paymentAttachments.noMatchedMedia)
|
||||
expect(attachmentPreview.text()).not.toContain('https://oss.example')
|
||||
|
||||
expect(attachmentPreview.findAll('[data-testid="payment-attachment-thumbnail"]')).toHaveLength(1)
|
||||
const thumbnail = attachmentPreview.find('[data-testid="payment-attachment-thumbnail"]')
|
||||
expect(thumbnail.exists()).toBe(true)
|
||||
expect(thumbnail.find('img').attributes('src')).toBe('https://oss.example/private/payment-image.jpg')
|
||||
await thumbnail.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const modal = wrapper.find('[data-testid="payment-attachment-modal"]')
|
||||
expect(modal.exists()).toBe(true)
|
||||
expect(modal.find('img').attributes('src')).toBe('https://oss.example/private/payment-image.jpg')
|
||||
expect(wrapper.text()).not.toContain('https://oss.example/private/payment-image.jpg')
|
||||
|
||||
const downloadLink = attachmentPreview.find('[data-testid="payment-attachment-download"]')
|
||||
expect(downloadLink.exists()).toBe(true)
|
||||
expect(downloadLink.attributes('href')).toBe('https://oss.example/private/payment-document.pdf')
|
||||
})
|
||||
|
||||
it('degrades Payment attachment preview when conversation media cannot be loaded', async () => {
|
||||
const detail = createOrderTaskDetail()
|
||||
usePaymentBusinessCard(detail)
|
||||
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('voucher-image.jpg')
|
||||
expect(wrapper.text()).toContain(zhCN.taskV4.paymentAttachments.loadFailed)
|
||||
expect(wrapper.find('[data-testid="payment-attachment-thumbnail"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="payment-attachment-download"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('confirms Payment cards with version only and never submits attachment ids or attachment URLs', async () => {
|
||||
const detail = createOrderTaskDetail()
|
||||
usePaymentBusinessCard(detail)
|
||||
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
|
||||
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({
|
||||
attachments: createPaymentConversationMedia(),
|
||||
}))
|
||||
vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue({
|
||||
...detail,
|
||||
business_cards: [
|
||||
{
|
||||
...detail.business_cards[0]!,
|
||||
card_status: 'CONFIRMED',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const wrapper = await mountWithPlugins(
|
||||
ReservationV4OrderTaskDetailView,
|
||||
'/reservation/order-tasks/9001',
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
const paymentCard = wrapper
|
||||
.findAll('.task-card-section')
|
||||
.find((section) => section.text().includes(zhCN.cardType.PAYMENT))
|
||||
expect(paymentCard).toBeTruthy()
|
||||
await paymentCard!.find('button.primary-button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(service.confirmReservationV4OrderTaskCard).toHaveBeenCalledWith('9001', 'card-payment', {
|
||||
version: 9,
|
||||
})
|
||||
const submittedRequest = vi.mocked(service.confirmReservationV4OrderTaskCard).mock.calls[0]?.[2]
|
||||
expect(JSON.stringify(submittedRequest)).not.toContain('attachment_ids')
|
||||
expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example')
|
||||
})
|
||||
|
||||
it('renders New Booking Room Information as a business form and confirms stable final values', async () => {
|
||||
const detail = createOrderTaskDetail({
|
||||
businessEventType: 'NEW_BOOKING',
|
||||
@@ -1131,6 +1230,7 @@ function createSourceMessageConversationResult(options: {
|
||||
sanitizedHtml?: string | null
|
||||
rawHtml?: string | null
|
||||
htmlRenderMode?: SourceMessageConversationResult['messages'][number]['html_render_mode']
|
||||
attachments?: SourceMessageOriginalMedia[]
|
||||
} = {}): SourceMessageConversationResult {
|
||||
const sourceMessageId = options.sourceMessageId ?? '30001'
|
||||
const currentBody = options.currentBody ?? 'Current trigger email body.'
|
||||
@@ -1157,7 +1257,7 @@ function createSourceMessageConversationResult(options: {
|
||||
html_body_sanitized: options.sanitizedHtml ?? null,
|
||||
html_render_mode: options.htmlRenderMode,
|
||||
received_at: '2026-07-08T03:00:00Z',
|
||||
attachments: [
|
||||
attachments: options.attachments ?? [
|
||||
{
|
||||
mediaType: 'ATTACHMENT',
|
||||
fileName: 'current-message.pdf',
|
||||
@@ -1200,6 +1300,95 @@ function createConversationMessage(
|
||||
}
|
||||
}
|
||||
|
||||
function usePaymentBusinessCard(detail: ReservationV4OrderTaskDetailResult): void {
|
||||
detail.business_cards = [
|
||||
createCard('card-payment', 'PAYMENT', 'PENDING_CONFIRM', {
|
||||
version: 9,
|
||||
display_payload: {
|
||||
payment_attachments: [
|
||||
{
|
||||
attachment_id: 'att-img-1',
|
||||
file_name: 'voucher-image.jpg',
|
||||
content_type: 'image/jpeg',
|
||||
size_bytes: 123456,
|
||||
is_image: true,
|
||||
preview_available: true,
|
||||
download_available: true,
|
||||
external_media_id: 'media-payment-image',
|
||||
},
|
||||
{
|
||||
attachment_id: 'att-pdf-1',
|
||||
file_name: 'voucher-document.pdf',
|
||||
content_type: 'application/pdf',
|
||||
size_bytes: 2048,
|
||||
is_image: false,
|
||||
preview_available: false,
|
||||
download_available: true,
|
||||
external_media_id: 'media-payment-document',
|
||||
},
|
||||
{
|
||||
attachment_id: 'att-missing-1',
|
||||
file_name: 'missing-voucher.jpg',
|
||||
content_type: 'image/jpeg',
|
||||
size_bytes: 4096,
|
||||
is_image: true,
|
||||
preview_available: false,
|
||||
download_available: false,
|
||||
external_media_id: 'media-payment-missing',
|
||||
unavailable_reason_code: 'MEDIA_NOT_AVAILABLE',
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
createField('/payment/attachment_ids', {
|
||||
display_name: 'attachment_ids',
|
||||
value: ['att-img-1', 'att-pdf-1'],
|
||||
editable: true,
|
||||
raw_readonly: false,
|
||||
edit_scope: 'CONFIRM',
|
||||
write_target: 'CONFIRMED_PAYLOAD_JSON',
|
||||
}),
|
||||
createField('/payment/payment_note', {
|
||||
display_name: 'Payment Note',
|
||||
value: 'Should not submit for Payment confirm.',
|
||||
editable: true,
|
||||
edit_scope: 'CONFIRM',
|
||||
write_target: 'CONFIRMED_PAYLOAD_JSON',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function createPaymentConversationMedia(): SourceMessageOriginalMedia[] {
|
||||
return [
|
||||
{
|
||||
mediaType: 'ATTACHMENT',
|
||||
fileName: 'voucher-image.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
sizeBytes: 123456,
|
||||
externalUrl: 'https://oss.example/private/payment-image.jpg',
|
||||
externalMediaId: 'media-payment-image',
|
||||
},
|
||||
{
|
||||
mediaType: 'ATTACHMENT',
|
||||
fileName: 'voucher-document.pdf',
|
||||
contentType: 'application/pdf',
|
||||
sizeBytes: 2048,
|
||||
externalUrl: 'https://oss.example/private/payment-document.pdf',
|
||||
externalMediaId: 'media-payment-document',
|
||||
},
|
||||
{
|
||||
mediaType: 'ATTACHMENT',
|
||||
fileName: 'missing-voucher.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
sizeBytes: 4096,
|
||||
externalUrl: 'https://oss.example/private/same-file-name-should-not-match.jpg',
|
||||
externalMediaId: 'media-different-same-file-name',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function createRoomInformationDisplayPayload(overrides: {
|
||||
event_type?: string
|
||||
booking_type?: string
|
||||
|
||||
@@ -404,6 +404,18 @@ export interface ReservationV4RoomInformationDisplayModel extends ReservationRec
|
||||
group_booking_status_options: ReservationV4RoomInformationStatusOption[]
|
||||
}
|
||||
|
||||
export interface ReservationV4PaymentAttachmentSummary extends ReservationRecord {
|
||||
attachment_id?: string | null
|
||||
file_name?: string | null
|
||||
content_type?: string | null
|
||||
size_bytes?: number | string | null
|
||||
is_image?: boolean | null
|
||||
preview_available?: boolean | null
|
||||
download_available?: boolean | null
|
||||
external_media_id?: string | null
|
||||
unavailable_reason_code?: string | null
|
||||
}
|
||||
|
||||
export interface ReservationV4TaskCardResult {
|
||||
card_id: string
|
||||
card_type: ReservationV4CardType
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
:validation-errors="cardFieldErrors[detail.basic_information_card.card_id] ?? {}"
|
||||
:hotel-id="detail.order_task.hotel_id"
|
||||
:order-task-id="detail.order_task.order_task_id"
|
||||
:source-message-id="detail.order_task.source_message_id"
|
||||
:action-errors="cardActionErrors[detail.basic_information_card.card_id] ?? []"
|
||||
:success-message="cardSuccessMessages[detail.basic_information_card.card_id] ?? ''"
|
||||
:review-form="reviewForms[detail.basic_information_card.card_id]"
|
||||
@@ -130,6 +131,7 @@
|
||||
:validation-errors="cardFieldErrors[businessCard.card_id] ?? {}"
|
||||
:hotel-id="detail.order_task.hotel_id"
|
||||
:order-task-id="detail.order_task.order_task_id"
|
||||
:source-message-id="detail.order_task.source_message_id"
|
||||
:action-errors="cardActionErrors[businessCard.card_id] ?? []"
|
||||
:success-message="cardSuccessMessages[businessCard.card_id] ?? ''"
|
||||
:review-form="reviewForms[businessCard.card_id]"
|
||||
@@ -198,6 +200,7 @@ import {
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type {
|
||||
ReservationAiTransitionDisplayResult,
|
||||
ReservationV4CardConfirmRequest,
|
||||
ReservationRecord,
|
||||
ReservationV4OrderTaskDetailResult,
|
||||
ReservationV4TaskCardResult,
|
||||
@@ -367,10 +370,11 @@ async function submitConfirm(card: ReservationV4TaskCardResult): Promise<void> {
|
||||
[card.card_id]: '',
|
||||
}
|
||||
try {
|
||||
const result = await confirmReservationV4OrderTaskCard(orderTaskId.value, card.card_id, {
|
||||
version: card.version,
|
||||
confirmed_payload: buildReservationV4ConfirmedPayload(submissionFields, cardValues.value[card.card_id] ?? {}),
|
||||
})
|
||||
const result = await confirmReservationV4OrderTaskCard(
|
||||
orderTaskId.value,
|
||||
card.card_id,
|
||||
buildConfirmRequest(card, submissionFields),
|
||||
)
|
||||
applyDetail(result)
|
||||
cardSuccessMessages.value = {
|
||||
...cardSuccessMessages.value,
|
||||
@@ -455,6 +459,9 @@ function editableFieldKeys(card: ReservationV4TaskCardResult): string[] {
|
||||
}
|
||||
|
||||
function submissionFieldsForCard(card: ReservationV4TaskCardResult): ReservationV4TaskCardResult['fields'] {
|
||||
if (isPaymentCard(card)) {
|
||||
return []
|
||||
}
|
||||
return card.card_type === 'ROOM_INFORMATION'
|
||||
? card.fields.filter((field) =>
|
||||
isReservationV4RoomInformationSafeField(field) && !isGroupRoomInformationBreakfastField(card, field),
|
||||
@@ -462,6 +469,25 @@ function submissionFieldsForCard(card: ReservationV4TaskCardResult): Reservation
|
||||
: card.fields
|
||||
}
|
||||
|
||||
function buildConfirmRequest(
|
||||
card: ReservationV4TaskCardResult,
|
||||
submissionFields: ReservationV4TaskCardResult['fields'],
|
||||
): ReservationV4CardConfirmRequest {
|
||||
if (isPaymentCard(card)) {
|
||||
return {
|
||||
version: card.version,
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: card.version,
|
||||
confirmed_payload: buildReservationV4ConfirmedPayload(submissionFields, cardValues.value[card.card_id] ?? {}),
|
||||
}
|
||||
}
|
||||
|
||||
function isPaymentCard(card: ReservationV4TaskCardResult): boolean {
|
||||
return card.card_type === 'PAYMENT'
|
||||
}
|
||||
|
||||
function isGroupRoomInformationBreakfastField(
|
||||
card: ReservationV4TaskCardResult,
|
||||
field: ReservationV4TaskCardResult['fields'][number],
|
||||
|
||||
Reference in New Issue
Block a user