调整预订事项字段展示与基础信息编辑

This commit is contained in:
andy
2026-07-22 10:34:26 +07:00
parent 08c8b0165b
commit 891a4aa6cf
10 changed files with 212 additions and 65 deletions

View File

@@ -7,9 +7,8 @@
:class="{ 'v4-field--readonly': !isEditable(field) }"
>
<span class="v4-field__label">
{{ field.display_name }}
{{ fieldDisplayName(field) }}
<sup v-if="field.required">*</sup>
<em v-if="!isEditable(field)">{{ t('taskV4.field.readonly') }}</em>
</span>
<select
@@ -99,11 +98,7 @@
</span>
<small
v-if="field.control_hint"
class="v4-field__hint"
>{{ field.control_hint }}</small>
<small
v-for="hint in lookupHints(field)"
v-for="hint in visibleLookupHints(field)"
:key="hint.key"
class="v4-field__hint"
:class="`v4-field__hint--${hint.tone}`"
@@ -227,6 +222,11 @@ function fieldKey(field: ReservationV4TaskCardFieldResult): string {
return reservationV4FieldKey(field)
}
function fieldDisplayName(field: ReservationV4TaskCardFieldResult): string {
const labelKey = fieldLabelKey(field)
return labelKey ? t(labelKey) : field.display_name
}
function dateFieldInputId(field: ReservationV4TaskCardFieldResult): string {
return `v4-date-${fieldKey(field).replace(/[^a-zA-Z0-9_-]/g, '-')}`
}
@@ -402,6 +402,10 @@ function lookupHints(field: ReservationV4TaskCardFieldResult): LookupHint[] {
return hints
}
function visibleLookupHints(field: ReservationV4TaskCardFieldResult): LookupHint[] {
return lookupHints(field).filter((hint) => hint.tone === 'error')
}
function visibleFieldErrors(field: ReservationV4TaskCardFieldResult): string[] {
return [
...(field.validation_errors ?? []),
@@ -645,6 +649,62 @@ function isExactLookupLoading(field: ReservationV4TaskCardFieldResult): boolean
function exactLookupKey(kind: ReservationV4LookupKind, code: string): string {
return `${props.hotelId ?? ''}:${kind}:${code}`
}
function fieldLabelKey(field: ReservationV4TaskCardFieldResult): string | null {
const key = normalizeFieldKey(field)
if (key === '/basic_information/account_code') {
return 'taskV4.fieldLabels.basicInformation.accountCode'
}
if (key === '/basic_information/market_code') {
return 'taskV4.fieldLabels.basicInformation.marketCode'
}
if (key === '/basic_information/source_code') {
return 'taskV4.fieldLabels.basicInformation.sourceCode'
}
if (key === '/room_information/final_values/group_block_name') {
return 'taskV4.roomInformation.groupBlockName'
}
if (key === '/room_information/final_values/fit_name') {
return 'taskV4.roomInformation.fitName'
}
if (key === '/room_information/final_values/arrival_date') {
return 'taskV4.roomInformation.arrivalDate'
}
if (key === '/room_information/final_values/departure_date') {
return 'taskV4.roomInformation.departureDate'
}
if (key === '/room_information/final_values/nights') {
return 'taskV4.roomInformation.nights'
}
if (key === '/room_information/final_values/rate_code') {
return 'taskV4.roomInformation.rateCode'
}
if (key === '/room_information/final_values/breakfast_included') {
return 'taskV4.roomInformation.breakfastIncluded'
}
if (key === '/room_information/final_values/group_booking_status') {
return 'taskV4.roomInformation.groupBookingStatus'
}
if (key === '/room_information/final_values/block_id') {
return 'taskV4.roomInformation.blockId'
}
if (key === '/room_information/final_values/confirmation_number') {
return 'taskV4.roomInformation.confirmationNumber'
}
if (/^\/room_information\/final_values\/room_items\/\d+\/room_type_code$/.test(key)) {
return 'taskV4.roomInformation.roomTypeCode'
}
if (/^\/room_information\/final_values\/room_items\/\d+\/room_count$/.test(key)) {
return 'taskV4.roomInformation.roomCount'
}
return null
}
function normalizeFieldKey(field: ReservationV4TaskCardFieldResult): string {
return reservationV4FieldKey(field)
.replace(/\./g, '/')
.replace(/^([^/])/, '/$1')
}
</script>
<style scoped>
@@ -672,15 +732,6 @@ function exactLookupKey(kind: ReservationV4LookupKind, code: string): string {
color: var(--th-color-danger);
}
.v4-field__label em {
border-radius: 999px;
background: var(--th-color-slate-100);
color: var(--th-color-slate-500);
font-size: 11px;
font-style: normal;
padding: 2px 6px;
}
.v4-field__control,
.v4-field__static {
min-height: 38px;

View File

@@ -31,7 +31,6 @@
<span class="trace-field__label">
{{ field.display_name }}
<sup v-if="field.required">*</sup>
<em v-if="!isEditable(field)">{{ t('taskV4.field.readonly') }}</em>
</span>
<select
@@ -94,14 +93,9 @@
</span>
<small
v-if="field.control_hint"
class="trace-field__hint"
>{{ field.control_hint }}</small>
<small
v-for="hint in lookupHints(field)"
:key="hint"
class="trace-field__hint"
>{{ hint }}</small>
v-if="lookupError(field)"
class="trace-field__hint trace-field__hint--error"
>{{ lookupError(field) }}</small>
<small
v-for="error in visibleFieldErrors(field)"
:key="error"
@@ -298,17 +292,14 @@ function selectDisabled(field: ReservationV4TaskCardFieldResult): boolean {
(roomTypeLookup.loading || Boolean(roomTypeLookup.error))
}
function lookupHints(field: ReservationV4TaskCardFieldResult): string[] {
function lookupError(field: ReservationV4TaskCardFieldResult): string {
if (reservationV4LookupKindForOptionsSource(field.options_source) !== 'ROOM_TYPE') {
return []
}
if (roomTypeLookup.loading) {
return [t('taskV4.lookup.loading')]
return ''
}
if (roomTypeLookup.error) {
return [roomTypeLookup.error]
return roomTypeLookup.error
}
return []
return ''
}
function visibleFieldErrors(field: ReservationV4TaskCardFieldResult): string[] {
@@ -488,15 +479,6 @@ function isRecord(value: unknown): value is ReservationRecord {
color: var(--th-color-danger);
}
.trace-field__label em {
border-radius: 999px;
background: var(--th-color-slate-100);
color: var(--th-color-slate-500);
font-size: 11px;
font-style: normal;
padding: 2px 6px;
}
.trace-field__control,
.trace-field__static {
min-height: 38px;
@@ -529,6 +511,11 @@ function isRecord(value: unknown): value is ReservationRecord {
font-size: 12px;
}
.trace-field__hint--error {
color: var(--th-color-danger);
font-weight: 800;
}
.trace-field__error {
color: var(--th-color-danger);
font-size: 12px;

View File

@@ -608,6 +608,13 @@ export default {
cardConfirmed: 'Card confirmed and detail refreshed.',
reviewResolved: 'Review submitted and detail refreshed.',
validationFailed: 'Fix current-card field errors first.',
fieldLabels: {
basicInformation: {
accountCode: 'Account Code',
marketCode: 'Market Code',
sourceCode: 'Source Code',
},
},
roomInformation: {
noDisplayModel: 'Room and date information is temporarily unavailable. Refresh later or contact an administrator.',
eventType: {

View File

@@ -608,6 +608,13 @@ export default {
cardConfirmed: 'ยืนยันการ์ดแล้วและรีเฟรชรายละเอียดแล้ว',
reviewResolved: 'ส่งผลตรวจสอบแล้วและรีเฟรชรายละเอียดแล้ว',
validationFailed: 'โปรดแก้ไขข้อมูลในการ์ดปัจจุบันก่อน',
fieldLabels: {
basicInformation: {
accountCode: 'รหัสบัญชี',
marketCode: 'รหัสตลาด',
sourceCode: 'รหัสแหล่งที่มา',
},
},
roomInformation: {
noDisplayModel: 'ข้อมูลห้องและวันที่ยังไม่พร้อมใช้งาน โปรดลองรีเฟรชภายหลังหรือติดต่อผู้ดูแลระบบ',
eventType: {
@@ -626,10 +633,10 @@ export default {
arrivalDate: 'วันเข้าพัก',
departureDate: 'วันออก',
nights: 'จำนวนคืน',
rateCode: 'Rate Code',
rateCode: 'รหัสราคา',
breakfastIncluded: 'รวมอาหารเช้า',
groupBookingStatus: 'สถานะกรุ๊ป',
blockId: 'Block ID',
blockId: 'รหัสบล็อก',
confirmationNumber: 'เลขยืนยัน',
roomItems: 'รายละเอียดห้อง',
roomTypeCode: 'รหัสประเภทห้อง',

View File

@@ -608,6 +608,13 @@ export default {
cardConfirmed: '卡片已确认,详情已刷新。',
reviewResolved: '复核已提交,详情已刷新。',
validationFailed: '请先修正当前卡片字段。',
fieldLabels: {
basicInformation: {
accountCode: '客户代码',
marketCode: '市场代码',
sourceCode: '来源代码',
},
},
roomInformation: {
noDisplayModel: '房型与日期信息暂不可用,请稍后刷新或联系管理员。',
eventType: {
@@ -626,10 +633,10 @@ export default {
arrivalDate: '入住日期',
departureDate: '离店日期',
nights: '晚数',
rateCode: 'Rate Code',
rateCode: '价格代码',
breakfastIncluded: '含早',
groupBookingStatus: '团队预订状态',
blockId: 'Block ID',
blockId: '团队预留编号',
confirmationNumber: '确认号',
roomItems: '房型明细',
roomTypeCode: '房型代码',

View File

@@ -115,8 +115,21 @@ describe('reservation V4 pages', () => {
await wrapper.find('select').setValue('ACC-LIVE')
await flushPromises()
expect(wrapper.text()).toContain('LEISURE')
expect(wrapper.text()).toContain('TRAVEL_AGENT')
const basicCard = wrapper.findAll('.task-card-section')[0]!
expect(basicCard.find('.v4-field__label em').exists()).toBe(false)
expect(basicCard.text()).not.toContain('Market 由 Account Code 派生')
expect(basicCard.text()).not.toContain('Source 由 Account Code 派生')
expect(basicCard.text()).not.toContain('选择后会带出市场')
const marketField = findFieldByLabel(basicCard, '市场代码')
const sourceField = findFieldByLabel(basicCard, '来源代码')
expect(marketField?.find('input[name="/basic_information/market_code"]').exists()).toBe(true)
expect(sourceField?.find('input[name="/basic_information/source_code"]').exists()).toBe(true)
expect((marketField?.find('input[name="/basic_information/market_code"]').element as HTMLInputElement).value)
.toBe('LEISURE')
expect((sourceField?.find('input[name="/basic_information/source_code"]').element as HTMLInputElement).value)
.toBe('TRAVEL_AGENT')
await marketField?.find('input[name="/basic_information/market_code"]').setValue('MICE')
await sourceField?.find('input[name="/basic_information/source_code"]').setValue('DIRECT')
await wrapper.findAll('button').find((button) => button.text().includes('确认卡片'))?.trigger('click')
await flushPromises()
@@ -125,6 +138,8 @@ describe('reservation V4 pages', () => {
confirmed_payload: {
basic_information: {
account_code: 'ACC-LIVE',
market_code: 'MICE',
source_code: 'DIRECT',
},
},
})
@@ -830,6 +845,9 @@ describe('reservation V4 pages', () => {
expect(roomCard.text()).not.toContain('target_order')
expect(roomCard.text()).not.toContain('Adult')
expect(roomCard.text()).not.toContain('Legacy Room Type')
expect(roomCard.text()).not.toContain('Group Block Name')
expect(roomCard.text()).not.toContain('Rate Code')
expect(roomCard.text()).not.toContain('Group Booking Status')
expect(roomCard.find('input[name="/room_information/final_values/adult"]').exists()).toBe(false)
expect(roomCard.find('input[name="/room_information/final_values/nights"]').exists()).toBe(false)
expect(roomCard.find('input[name="/room_information/final_values/target_order/locator_value"]').exists()).toBe(false)
@@ -1194,7 +1212,7 @@ describe('reservation V4 pages', () => {
)
await flushPromises()
expect(wrapper.text()).toContain(zhCN.taskV4.lookup.empty)
expect(wrapper.text()).not.toContain(zhCN.taskV4.lookup.empty)
expect(wrapper.text()).not.toContain(zhCN.taskV4.lookup.stale)
expect(wrapper.text()).not.toContain('PMS sync is stale')
expect(wrapper.text()).not.toContain('PMS_SYNC')
@@ -1307,9 +1325,6 @@ describe('reservation V4 pages', () => {
page_num: 1,
page_size: 20,
})
expect(wrapper.text()).toContain('MICE')
expect(wrapper.text()).toContain('DIRECT')
await wrapper.findAll('button').find((button) => button.text().includes('确认卡片'))?.trigger('click')
await flushPromises()
@@ -1318,6 +1333,8 @@ describe('reservation V4 pages', () => {
confirmed_payload: {
basic_information: {
account_code: 'ACC-101',
market_code: 'LEISURE',
source_code: 'TRAVEL_AGENT',
},
},
})
@@ -1347,7 +1364,7 @@ describe('reservation V4 pages', () => {
)
await flushPromises()
expect(wrapper.text()).toContain(zhCN.taskV4.lookup.empty)
expect(wrapper.text()).not.toContain(zhCN.taskV4.lookup.empty)
expect(wrapper.text()).not.toContain('固定种子初始化')
expect(wrapper.text()).not.toContain('真实 PMS')
expect(wrapper.text()).not.toContain('PMS_SYNC')
@@ -1582,6 +1599,8 @@ function createOrderTaskDetail(options: {
businessFields?: ReservationV4TaskCardResult['fields']
sourceDisplayPayload?: ReservationV4TaskCardResult['display_payload']
basicAccountValue?: string
basicMarketCodeValue?: string
basicSourceCodeValue?: string
basicAccountRequired?: boolean
} = {}): ReservationV4OrderTaskDetailResult {
const sourceCard = createCard('card-source', 'SOURCE_MESSAGE_DISPLAY', 'READONLY', {
@@ -1609,6 +1628,26 @@ function createOrderTaskDetail(options: {
options_source: 'RESERVATION_V4_ACCOUNT_CATALOG',
control_type: 'SELECT',
}),
createField('/basic_information/market_code', {
display_name: 'Market Code',
value: options.basicMarketCodeValue ?? 'LEISURE',
editable: false,
raw_readonly: true,
control_type: 'READONLY',
edit_scope: 'NEVER',
write_target: 'NONE',
control_hint: 'Market 由 Account Code 派生,前端只读展示。',
}),
createField('/basic_information/source_code', {
display_name: 'Source Code',
value: options.basicSourceCodeValue ?? 'TRAVEL_AGENT',
editable: false,
raw_readonly: true,
control_type: 'READONLY',
edit_scope: 'NEVER',
write_target: 'NONE',
control_hint: 'Source 由 Account Code 派生,前端只读展示。',
}),
createField('/basic_information/read_only_marker', {
display_name: 'Read only marker',
value: 'VISIBLE',

View File

@@ -283,11 +283,12 @@ async function loadDetail(): Promise<void> {
}
function applyDetail(result: ReservationV4OrderTaskDetailResult): void {
detail.value = result
const normalizedResult = normalizeReservationV4Detail(result)
detail.value = normalizedResult
const cards = [
result.source_message_card,
result.basic_information_card,
...result.business_cards,
normalizedResult.source_message_card,
normalizedResult.basic_information_card,
...normalizedResult.business_cards,
].filter((card): card is ReservationV4TaskCardResult => Boolean(card))
cardValues.value = cards.reduce<Record<string, ReservationRecord>>((values, card) => {
values[card.card_id] = buildReservationV4InitialFieldValues(card.fields)
@@ -298,13 +299,60 @@ function applyDetail(result: ReservationV4OrderTaskDetailResult): void {
cardSuccessMessages.value = {}
reviewForms.value = cards.reduce<Record<string, { confirmed_order_id: string; reason: string }>>((forms, card) => {
forms[card.card_id] = {
confirmed_order_id: result.order_task.order_id ?? '',
confirmed_order_id: normalizedResult.order_task.order_id ?? '',
reason: '',
}
return forms
}, {})
}
function normalizeReservationV4Detail(result: ReservationV4OrderTaskDetailResult): ReservationV4OrderTaskDetailResult {
return {
...result,
basic_information_card: result.basic_information_card
? normalizeBasicInformationCard(result.basic_information_card)
: result.basic_information_card,
}
}
function normalizeBasicInformationCard(card: ReservationV4TaskCardResult): ReservationV4TaskCardResult {
return {
...card,
fields: card.fields.map((field) =>
isEditableBasicMarketSourceField(field)
? normalizeEditableBasicMarketSourceField(card, field)
: field,
),
}
}
function normalizeEditableBasicMarketSourceField(
card: ReservationV4TaskCardResult,
field: ReservationV4TaskCardResult['fields'][number],
): ReservationV4TaskCardResult['fields'][number] {
const reviewMode = card.card_status === 'REVIEW_REQUIRED'
return {
...field,
editable: true,
raw_readonly: false,
control_type: 'TEXT',
edit_scope: reviewMode ? 'REVIEW' : 'CONFIRM',
write_target: reviewMode ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON',
control_hint: null,
}
}
function isEditableBasicMarketSourceField(field: ReservationV4TaskCardResult['fields'][number]): boolean {
const key = normalizeV4FieldKey(field)
return key === '/basic_information/market_code' || key === '/basic_information/source_code'
}
function normalizeV4FieldKey(field: ReservationV4TaskCardResult['fields'][number]): string {
return reservationV4FieldKey(field)
.replace(/\./g, '/')
.replace(/^([^/])/, '/$1')
}
function setCardValues(card: ReservationV4TaskCardResult, values: ReservationRecord): void {
cardValues.value = {
...cardValues.value,