前端调整V4预订事项用户化展示

This commit is contained in:
andy committed 2026-07-21 20:54:24 +07:00
1 parent 350bb3310b
commit c521eb6dc0
20 files changed
+703 -657

No files matched your search

@@ -5,17 +5,7 @@
<h2 class="th-section-title">
{{ title }}
</h2>
<p
v-if="sourceMessageId"
class="th-muted"
>
{{ t('task.sourceMessageId') }}: {{ sourceMessageId }}
</p>
</div>
<ReservationStatusBadge
v-if="sourceCard"
:status="sourceCard.card_status"
/>
</div>
<div class="source-grid">
@@ -45,11 +35,16 @@
<div class="source-preview source-body">
<h3>{{ t('taskV4.sourceMessage.body') }}</h3>
<p
v-if="bodyLoading"
v-if="!bodyExpanded"
class="source-body__muted"
>
{{ t('taskV4.sourceMessage.bodyLoading') }}
{{ t('taskV4.sourceMessage.bodyCollapsed') }}
</p>
<template v-else-if="bodyLoading">
<p class="source-body__muted">
{{ t('taskV4.sourceMessage.bodyLoading') }}
</p>
</template>
<p
v-else-if="bodyError"
class="source-body__muted"
@@ -59,7 +54,7 @@
<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"
v-if="sanitizedHtmlBody"
class="source-body__html"
v-html="sanitizedHtmlBody"
/>
@@ -68,16 +63,8 @@
v-else
class="source-body__text"
>
{{ visibleMessageBodyText }}
{{ messageBodyText }}
</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
@@ -85,6 +72,13 @@
>
{{ t('taskV4.sourceMessage.noBody') }}
</p>
<button
type="button"
class="source-body__toggle"
@click="toggleBody"
>
{{ bodyExpanded ? t('taskV4.sourceMessage.collapseBody') : t('taskV4.sourceMessage.showFullBody') }}
</button>
</div>
<div class="source-preview">
@@ -125,7 +119,6 @@ import { computed, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
import { fetchSourceMessageConversation } from '@/services/reservationService'
import type {
ReservationRecord,
@@ -145,7 +138,6 @@ const props = withDefaults(defineProps<{
})
const { t } = useI18n()
const bodyPreviewLength = 360
const currentMessage = ref<SourceMessageConversationItem | null>(null)
const bodyLoading = ref(false)
const bodyError = ref(false)
@@ -210,19 +202,8 @@ const messageBodyText = computed(() => {
}
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)
watch(sourceMessageId, () => {
resetBodyState()
}, { immediate: true })
async function loadCurrentSourceMessage(nextSourceMessageId: string | null) {
@@ -230,7 +211,6 @@ async function loadCurrentSourceMessage(nextSourceMessageId: string | null) {
const requestSequence = bodyRequestSequence
currentMessage.value = null
bodyError.value = false
bodyExpanded.value = false
if (!nextSourceMessageId) {
bodyLoading.value = false
@@ -257,6 +237,21 @@ async function loadCurrentSourceMessage(nextSourceMessageId: string | null) {
}
}
function toggleBody(): void {
bodyExpanded.value = !bodyExpanded.value
if (bodyExpanded.value && !currentMessage.value && !bodyLoading.value) {
void loadCurrentSourceMessage(sourceMessageId.value)
}
}
function resetBodyState(): void {
bodyRequestSequence += 1
currentMessage.value = null
bodyError.value = false
bodyExpanded.value = false
bodyLoading.value = false
}
interface AttachmentSummary {
key: string
name: string
@@ -390,16 +390,6 @@ function lookupHints(field: ReservationV4TaskCardFieldResult): LookupHint[] {
tone: 'warning',
})
}
if (result.catalog_source || result.catalog_version) {
hints.push({
key: `${kind}-meta`,
message: t('taskV4.lookup.catalogMeta', {
source: result.catalog_source ?? '-',
version: result.catalog_version ?? '-',
}),
tone: 'normal',
})
}
const invalidValue = invalidLookupValues[reservationV4FieldKey(field)]
if (invalidValue) {
hints.push({
@@ -8,30 +8,31 @@
<h2 class="th-section-title">
{{ title }}
</h2>
<p class="task-card-section__id">
#{{ card.card_id }}
</p>
</div>
<ReservationStatusBadge :status="card.card_status" />
<div class="task-card-section__status-actions">
<ReservationStatusBadge :status="card.card_status" />
<button
v-if="showPrimaryAction"
type="button"
class="primary-button"
:disabled="primaryActionDisabled"
@click="handlePrimaryAction"
>
{{ submitting ? t('taskV4.confirmingCard') : t('taskV4.confirmCard') }}
</button>
</div>
</div>
<div class="card-meta-grid">
<div class="card-meta-item">
<span>{{ t('task.reviewStatus') }}</span>
<strong>{{ card.review_status ?? '-' }}</strong>
</div>
<div class="card-meta-item">
<span>{{ t('taskV4.readonlyReason') }}</span>
<strong>{{ availabilityReason || '-' }}</strong>
</div>
<div class="card-meta-item">
<span>{{ t('taskV4.blockedReason') }}</span>
<strong>{{ card.availability.blocked_reason ?? '-' }}</strong>
</div>
<div class="card-meta-item">
<span>{{ t('taskV4.orderTaskId') }}</span>
<strong>{{ orderTaskId ?? '-' }}</strong>
</div>
<div
v-if="cardNotices.length"
class="card-notice-list"
>
<p
v-for="notice in cardNotices"
:key="notice"
>
{{ notice }}
</p>
</div>
<ReservationV4RoomInformationCard
@@ -81,24 +82,6 @@
/>
</template>
<details
v-if="!isRoomInformationCard && !isRoomingListCard && !isTraceCard && safePayloadRows.length"
class="safe-payload"
>
<summary>
<span>{{ t('taskV4.displayPayload') }}</span>
</summary>
<dl>
<template
v-for="row in safePayloadRows"
:key="row.key"
>
<dt>{{ row.key }}</dt>
<dd>{{ row.value }}</dd>
</template>
</dl>
</details>
<div
v-if="actionErrors.length"
class="action-message action-message--error"
@@ -141,39 +124,13 @@
@input="updateReviewForm('reason', $event)"
/>
</label>
<div class="review-box__actions">
<button
type="button"
class="primary-button"
:disabled="!canReview"
@click="emit('resolveReview')"
>
{{ submitting ? t('taskV4.confirmingCard') : t('taskV4.confirmCard') }}
</button>
<RouterLink
v-if="boundOrderId && !isRoomingListCard"
class="card-order-link"
:to="`/reservation/orders/${boundOrderId}`"
>
{{ t('task.viewOrder') }}
</RouterLink>
</div>
</div>
<div
v-else
class="card-actions"
v-if="showOrderLink"
class="card-secondary-actions"
>
<button
type="button"
class="primary-button"
:disabled="!canConfirm"
@click="emit('confirm')"
>
{{ submitting ? t('taskV4.confirmingCard') : t('taskV4.confirmCard') }}
</button>
<RouterLink
v-if="boundOrderId && !isRoomingListCard"
class="card-order-link"
:to="`/reservation/orders/${boundOrderId}`"
>
@@ -196,10 +153,6 @@ import ReservationV4TaskCardFieldRenderer from '@/components/reservation/Reserva
import ReservationV4TraceCard from '@/components/reservation/ReservationV4TraceCard.vue'
import type { ReservationRecord, ReservationV4TaskCardResult } from '@/types/reservation'
import { formatReservationReadonlyReason } from '@/utils/reservationDisplay'
import {
isReservationV4UnsafeDisplayKey,
stringifyReservationV4SafeDisplayValue,
} from '@/utils/reservationV4FieldRules'
type ReviewForm = {
confirmed_order_id: string
@@ -214,7 +167,6 @@ const props = withDefaults(defineProps<{
editableKeys: string[]
validationErrors?: Record<string, string>
hotelId?: string
orderTaskId?: string | null
sourceMessageId?: string | null
orderReference?: string | null
targetLocator?: string | null
@@ -229,7 +181,6 @@ const props = withDefaults(defineProps<{
}>(), {
validationErrors: () => ({}),
hotelId: undefined,
orderTaskId: null,
sourceMessageId: null,
orderReference: null,
targetLocator: null,
@@ -267,22 +218,17 @@ const availabilityReason = computed(() => {
? formatReservationReadonlyReason(t, props.card.availability.readonly_reason_code)
: '')
})
const safePayloadRows = computed(() => {
if (!props.card.display_payload) {
return []
}
return Object.entries(props.card.display_payload)
.filter(([key]) => !isReservationV4UnsafeDisplayKey(key))
.slice(0, 12)
.map(([key, value]) => ({
key,
value: stringifyReservationV4SafeDisplayValue(
value,
t('taskV4.field.empty'),
t('taskV4.field.hidden'),
),
}))
const showPrimaryAction = computed(() =>
props.card.card_status === 'PENDING_CONFIRM' || props.card.card_status === 'REVIEW_REQUIRED',
)
const primaryActionDisabled = computed(() =>
props.card.card_status === 'REVIEW_REQUIRED' ? !props.canReview : !props.canConfirm,
)
const showOrderLink = computed(() => Boolean(props.boundOrderId && !isRoomingListCard.value))
const cardNotices = computed(() => {
return [
availabilityReason.value,
].filter((notice): notice is string => Boolean(notice?.trim()))
})
function updateReviewForm(field: keyof ReviewForm, event: Event): void {
@@ -297,6 +243,14 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
const normalizedKey = `${field.field_pointer || ''} ${field.field_path || ''}`.toLowerCase()
return normalizedKey.includes('attachment_ids')
}
function handlePrimaryAction(): void {
if (props.card.card_status === 'REVIEW_REQUIRED') {
emit('resolveReview')
return
}
emit('confirm')
}
</script>
<style scoped>
@@ -306,7 +260,7 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
.task-card-section__header {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--th-color-slate-200);
@@ -319,42 +273,28 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
min-width: 0;
}
.task-card-section__id {
margin: 0;
color: var(--th-color-slate-500);
font-size: 13px;
font-weight: 700;
overflow-wrap: anywhere;
.task-card-section__status-actions {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.card-meta-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
padding: 16px 20px 18px;
}
.card-meta-item {
.card-notice-list {
display: grid;
gap: 6px;
min-width: 0;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
border-bottom: 1px solid var(--th-color-slate-100);
background: var(--th-color-slate-50);
padding: 10px 12px;
padding: 12px 20px;
}
.card-meta-item span {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 800;
}
.card-meta-item strong {
color: var(--th-color-slate-900);
.card-notice-list p {
margin: 0;
color: var(--th-color-slate-600);
font-size: 13px;
line-height: 1.45;
overflow-wrap: anywhere;
font-weight: 700;
line-height: 1.5;
}
.task-card-section__fields {
@@ -387,7 +327,7 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
opacity: 0.55;
}
.card-actions {
.card-secondary-actions {
display: flex;
align-items: center;
gap: 14px;
@@ -396,7 +336,7 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
.review-box {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: 12px;
align-items: end;
border-top: 1px solid var(--th-color-slate-200);
@@ -433,12 +373,6 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
outline: 0;
}
.review-box__actions {
display: grid;
gap: 10px;
justify-items: start;
}
.action-message {
margin: 0 20px 18px;
border-radius: var(--th-radius-sm);
@@ -465,75 +399,6 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
color: var(--th-color-success);
}
.safe-payload {
margin: 0 20px 18px;
}
.safe-payload summary {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 38px;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
color: var(--th-color-slate-700);
cursor: pointer;
font-size: 13px;
font-weight: 800;
list-style: none;
padding: 9px 12px;
transition: background-color 160ms ease, border-color 160ms ease;
}
.safe-payload summary::-webkit-details-marker {
display: none;
}
.safe-payload summary::after {
content: '';
width: 8px;
height: 8px;
border-right: 2px solid var(--th-color-slate-500);
border-bottom: 2px solid var(--th-color-slate-500);
transform: rotate(45deg) translateY(-2px);
transition: transform 160ms ease;
}
.safe-payload[open] summary {
border-color: var(--th-color-blue-600);
background: var(--th-color-info-bg);
}
.safe-payload[open] summary::after {
transform: rotate(225deg) translateY(-2px);
}
.safe-payload dl {
display: grid;
grid-template-columns: minmax(120px, 220px) minmax(0, 1fr);
gap: 8px 12px;
margin: 0;
border: 1px solid var(--th-color-slate-200);
border-top: 0;
border-radius: 0 0 var(--th-radius-sm) var(--th-radius-sm);
background: var(--th-color-slate-50);
padding: 12px;
}
.safe-payload dt {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 800;
}
.safe-payload dd {
margin: 0;
color: var(--th-color-slate-900);
font-size: 12px;
overflow-wrap: anywhere;
}
.card-order-link {
display: inline-flex;
align-items: center;
@@ -548,14 +413,19 @@ function isPaymentAttachmentIdField(field: ReservationV4TaskCardResult['fields']
}
@media (max-width: 980px) {
.card-meta-grid,
.task-card-section__header,
.review-box {
grid-template-columns: 1fr;
}
.card-actions {
.task-card-section__header,
.card-secondary-actions {
align-items: flex-start;
flex-direction: column;
}
.task-card-section__status-actions {
justify-content: flex-start;
}
}
</style>