优化V4订单任务卡片样式

This commit is contained in:
andy committed 2026-07-20 13:22:57 +07:00
1 parent cf3531a748
commit b50e004b06
3 files changed
+571 -274

No files matched your search

@@ -0,0 +1,494 @@
<template>
<section class="th-section task-card-section">
<div class="task-card-section__header">
<div class="task-card-section__heading">
<h2 class="th-section-title">
{{ title }}
</h2>
<p class="task-card-section__id">
#{{ card.card_id }}
</p>
</div>
<ReservationStatusBadge :status="card.card_status" />
</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>
<ReservationV4TaskCardFieldRenderer
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)"
/>
<details
v-if="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"
>
<p
v-for="message in actionErrors"
:key="message"
>
{{ message }}
</p>
</div>
<p
v-if="successMessage"
class="action-message action-message--success"
>
{{ successMessage }}
</p>
<div
v-if="card.card_status === 'REVIEW_REQUIRED'"
class="review-box"
>
<label>
<span>{{ t('taskV4.confirmedOrderId') }}</span>
<input
:value="reviewForm.confirmed_order_id"
:placeholder="t('taskV4.confirmedOrderIdPlaceholder')"
@input="updateReviewForm('confirmed_order_id', $event)"
>
</label>
<label>
<span>{{ t('taskV4.reviewReason') }}</span>
<textarea
:value="reviewForm.reason"
:placeholder="t('taskV4.reviewReasonPlaceholder')"
rows="3"
@input="updateReviewForm('reason', $event)"
/>
</label>
<div class="review-box__actions">
<button
type="button"
class="primary-button"
:disabled="!canReview"
@click="emit('resolveReview')"
>
{{ submitting ? t('taskV4.resolvingReview') : t('taskV4.resolveReview') }}
</button>
<RouterLink
v-if="boundOrderId"
class="card-order-link"
:to="`/reservation/orders/${boundOrderId}`"
>
{{ t('task.viewOrder') }}
</RouterLink>
</div>
</div>
<div
v-else
class="card-actions"
>
<button
type="button"
class="primary-button"
:disabled="!canConfirm"
@click="emit('confirm')"
>
{{ submitting ? t('taskV4.confirmingCard') : t('taskV4.confirmCard') }}
</button>
<RouterLink
v-if="boundOrderId"
class="card-order-link"
:to="`/reservation/orders/${boundOrderId}`"
>
{{ t('task.viewOrder') }}
</RouterLink>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } 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 type { ReservationRecord, ReservationV4TaskCardResult } from '@/types/reservation'
import { formatReservationReadonlyReason } from '@/utils/reservationDisplay'
import {
isReservationV4UnsafeDisplayKey,
stringifyReservationV4SafeDisplayValue,
} from '@/utils/reservationV4FieldRules'
type ReviewForm = {
confirmed_order_id: string
reason: string
}
const props = withDefaults(defineProps<{
card: ReservationV4TaskCardResult
title: string
modelValue: ReservationRecord
readOnly: boolean
editableKeys: string[]
validationErrors?: Record<string, string>
hotelId?: string
orderTaskId?: string | null
actionErrors?: string[]
successMessage?: string
reviewForm?: ReviewForm
submitting: boolean
canConfirm: boolean
canReview: boolean
boundOrderId?: string | null
}>(), {
validationErrors: () => ({}),
hotelId: undefined,
orderTaskId: null,
actionErrors: () => [],
successMessage: '',
reviewForm: () => ({
confirmed_order_id: '',
reason: '',
}),
boundOrderId: null,
})
const emit = defineEmits<{
'update:modelValue': [value: ReservationRecord]
'update:reviewForm': [value: ReviewForm]
confirm: []
resolveReview: []
}>()
const { t } = useI18n()
const availabilityReason = computed(() => {
return props.card.availability.readonly_reason_message ??
(props.card.availability.readonly_reason_code
? 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'),
),
}))
})
function updateReviewForm(field: keyof ReviewForm, event: Event): void {
const target = event.target as HTMLInputElement | HTMLTextAreaElement
emit('update:reviewForm', {
...props.reviewForm,
[field]: target.value,
})
}
</script>
<style scoped>
.task-card-section {
overflow: hidden;
}
.task-card-section__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--th-color-slate-200);
padding: 20px;
}
.task-card-section__heading {
display: grid;
gap: 8px;
min-width: 0;
}
.task-card-section__id {
margin: 0;
color: var(--th-color-slate-500);
font-size: 13px;
font-weight: 700;
overflow-wrap: anywhere;
}
.card-meta-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
padding: 16px 20px 18px;
}
.card-meta-item {
display: grid;
gap: 6px;
min-width: 0;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-slate-50);
padding: 10px 12px;
}
.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);
font-size: 13px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.task-card-section__fields {
padding: 0 20px 18px;
}
.primary-button {
min-height: 38px;
border: 0;
border-radius: var(--th-radius-sm);
background: var(--th-color-blue-600);
color: var(--th-color-white);
cursor: pointer;
font-size: 13px;
font-weight: 800;
padding: 8px 14px;
transition: background-color 160ms ease, transform 160ms ease, opacity 160ms ease;
}
.primary-button:hover:not(:disabled) {
background: color-mix(in srgb, var(--th-color-blue-600) 86%, var(--th-color-navy-950));
}
.primary-button:active:not(:disabled) {
transform: translateY(1px);
}
.primary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.card-actions {
display: flex;
align-items: center;
gap: 14px;
padding: 0 20px 20px;
}
.review-box {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) auto;
gap: 12px;
align-items: end;
border-top: 1px solid var(--th-color-slate-200);
background: linear-gradient(180deg, var(--th-color-white), var(--th-color-slate-50));
padding: 16px 20px 18px;
}
.review-box label {
display: grid;
gap: 7px;
}
.review-box span {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 800;
}
.review-box input,
.review-box textarea {
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-900);
font: inherit;
padding: 8px 10px;
}
.review-box input:focus,
.review-box textarea:focus {
border-color: var(--th-color-blue-600);
box-shadow: 0 0 0 3px var(--th-color-info-bg);
outline: 0;
}
.review-box__actions {
display: grid;
gap: 10px;
justify-items: start;
}
.action-message {
margin: 0 20px 18px;
border-radius: var(--th-radius-sm);
font-size: 13px;
font-weight: 700;
padding: 10px 12px;
}
.action-message p {
margin: 0;
}
.action-message p + p {
margin-top: 5px;
}
.action-message--error {
background: var(--th-color-danger-bg);
color: var(--th-color-danger);
}
.action-message--success {
background: var(--th-color-success-bg);
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;
color: var(--th-color-blue-600);
font-size: 13px;
font-weight: 800;
text-decoration: none;
}
.card-order-link:hover {
text-decoration: underline;
}
@media (max-width: 980px) {
.card-meta-grid,
.review-box {
grid-template-columns: 1fr;
}
.card-actions {
align-items: flex-start;
flex-direction: column;
}
}
</style>