接入V4任务卡目录查询

This commit is contained in:
andy
2026-07-19 15:35:58 +07:00
parent 8b37232ac9
commit d808732107
16 changed files with 803 additions and 38 deletions

View File

@@ -16,6 +16,7 @@
v-if="isEditable(field) && isSelectField(field)"
class="v4-field__control"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:disabled="selectDisabled(field)"
:value="fieldValue(field)"
@change="updateField(field, $event)"
>
@@ -76,6 +77,12 @@
v-if="field.control_hint"
class="v4-field__hint"
>{{ field.control_hint }}</small>
<small
v-for="hint in lookupHints(field)"
:key="hint.key"
class="v4-field__hint"
:class="`v4-field__hint--${hint.tone}`"
>{{ hint.message }}</small>
<small
v-for="error in visibleFieldErrors(field)"
:key="error"
@@ -86,15 +93,27 @@
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ReservationRecord, ReservationV4TaskCardFieldResult } from '@/types/reservation'
import {
fetchReservationV4AccountLookups,
fetchReservationV4RateCodeLookups,
fetchReservationV4RoomTypeLookups,
} from '@/services/reservationService'
import type {
ReservationRecord,
ReservationV4CatalogLookupItem,
ReservationV4CatalogLookupResult,
ReservationV4TaskCardFieldResult,
} from '@/types/reservation'
import {
isReservationV4EditableField,
readV4FieldValue,
reservationV4LookupKindForOptionsSource,
reservationV4FieldKey,
staticReservationV4Options,
stringifyReservationV4SafeDisplayValue,
type ReservationV4LookupKind,
} from '@/utils/reservationV4FieldRules'
const props = defineProps<{
@@ -103,6 +122,7 @@ const props = defineProps<{
readOnly: boolean
validationErrors?: Record<string, string>
editableKeys?: string[]
hotelId?: string
}>()
const emit = defineEmits<{
@@ -110,6 +130,65 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
type FieldOption = {
value: string
label: string
item?: ReservationV4CatalogLookupItem
}
type LookupHintTone = 'normal' | 'warning' | 'error'
type LookupHint = {
key: string
message: string
tone: LookupHintTone
}
type LookupState = {
loading: boolean
error: string
result: ReservationV4CatalogLookupResult | null
}
const invalidLookupValues = reactive<Record<string, string>>({})
const lookupStates = reactive<Record<ReservationV4LookupKind, LookupState>>({
ACCOUNT: createLookupState(),
ROOM_TYPE: createLookupState(),
RATE_CODE: createLookupState(),
})
const lookupRequestVersions: Record<ReservationV4LookupKind, number> = {
ACCOUNT: 0,
ROOM_TYPE: 0,
RATE_CODE: 0,
}
const lookupKinds = computed(() => {
const kinds = props.fields
.filter((field) => isSelectControl(field) && isReservationV4EditableField(field))
.map((field) => reservationV4LookupKindForOptionsSource(field.options_source))
.filter((kind): kind is ReservationV4LookupKind => Boolean(kind))
return [...new Set(kinds)]
})
watch(
[lookupKinds, () => props.hotelId],
() => {
resetLookupStates()
lookupKinds.value.forEach((kind) => {
void loadLookup(kind)
})
},
{ immediate: true },
)
watch(
[
() => props.modelValue,
() => lookupStates.ACCOUNT.result,
() => lookupStates.ROOM_TYPE.result,
() => lookupStates.RATE_CODE.result,
],
() => {
clearInvalidLoadedCatalogValues()
},
{ deep: true },
)
function fieldKey(field: ReservationV4TaskCardFieldResult): string {
return reservationV4FieldKey(field)
@@ -123,7 +202,7 @@ function isEditable(field: ReservationV4TaskCardFieldResult): boolean {
}
function isSelectField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'SELECT' && fieldOptions(field).length > 0
return isSelectControl(field) && Boolean(reservationV4LookupKindForOptionsSource(field.options_source))
}
function isDateField(field: ReservationV4TaskCardFieldResult): boolean {
@@ -138,8 +217,25 @@ function isTextAreaField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'TEXTAREA'
}
function fieldOptions(field: ReservationV4TaskCardFieldResult): Array<{ value: string; label: string }> {
return staticReservationV4Options(field.options_source)
function fieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
const options = kind
? (lookupStates[kind].result?.items ?? []).map((item) => ({
value: item.code,
label: lookupItemLabel(item),
item,
}))
: []
return options
}
function selectDisabled(field: ReservationV4TaskCardFieldResult): boolean {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
if (!kind) {
return false
}
const state = lookupStates[kind]
return state.loading || Boolean(state.error)
}
function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
@@ -158,12 +254,92 @@ function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
function updateField(field: ReservationV4TaskCardFieldResult, event: Event): void {
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
delete invalidLookupValues[reservationV4FieldKey(field)]
emit('update:modelValue', {
...props.modelValue,
[reservationV4FieldKey(field)]: target.value,
})
}
function lookupHints(field: ReservationV4TaskCardFieldResult): LookupHint[] {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
if (!kind) {
return []
}
const state = lookupStates[kind]
if (state.loading) {
return [{
key: `${kind}-loading`,
message: t('taskV4.lookup.loading'),
tone: 'normal',
}]
}
if (state.error) {
return [{
key: `${kind}-error`,
message: state.error,
tone: 'error',
}]
}
const result = state.result
if (!result) {
return []
}
const hints: LookupHint[] = []
const selectedItem = selectedLookupItem(field)
if (kind === 'ACCOUNT' && selectedItem && (selectedItem.market_code || selectedItem.source_code)) {
hints.push({
key: `${kind}-derived`,
message: t('taskV4.lookup.accountDerived', {
market: selectedItem.market_code ?? '-',
source: selectedItem.source_code ?? '-',
}),
tone: 'normal',
})
}
if (result.stale) {
hints.push({
key: `${kind}-stale`,
message: t('taskV4.lookup.stale'),
tone: 'warning',
})
}
result.warnings.forEach((warning, index) => {
hints.push({
key: `${kind}-warning-${index}`,
message: t('taskV4.lookup.warning', { warning }),
tone: 'warning',
})
})
if (!result.items.length) {
hints.push({
key: `${kind}-empty`,
message: t('taskV4.lookup.empty'),
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({
key: `${kind}-invalid-current`,
message: t('taskV4.lookup.currentNotInCatalog', { value: invalidValue }),
tone: 'error',
})
}
return hints
}
function visibleFieldErrors(field: ReservationV4TaskCardFieldResult): string[] {
return [
...(field.validation_errors ?? []),
@@ -186,6 +362,122 @@ function stringifyV4Value(value: unknown): string {
function normalizeControlType(value: string | null): string {
return value?.trim().replace(/[\s-]+/g, '_').toUpperCase() ?? ''
}
function isSelectControl(field: ReservationV4TaskCardFieldResult): boolean {
return ['SELECT', 'LOOKUP'].includes(normalizeControlType(field.control_type))
}
function lookupItemLabel(item: ReservationV4CatalogLookupItem): string {
if (item.display_name && item.display_name !== item.code) {
return `${item.code} - ${item.display_name}`
}
return item.code
}
function selectedLookupItem(field: ReservationV4TaskCardFieldResult): ReservationV4CatalogLookupItem | null {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
const value = String(fieldValue(field) || '')
if (!kind || !value) {
return null
}
return lookupStates[kind].result?.items.find((item) => item.code === value) ?? null
}
function createLookupState(): LookupState {
return {
loading: false,
error: '',
result: null,
}
}
function resetLookupStates(): void {
const allKinds: ReservationV4LookupKind[] = ['ACCOUNT', 'ROOM_TYPE', 'RATE_CODE']
allKinds.forEach((kind) => {
lookupRequestVersions[kind] += 1
lookupStates[kind].loading = false
lookupStates[kind].error = ''
lookupStates[kind].result = null
})
Object.keys(invalidLookupValues).forEach((key) => {
delete invalidLookupValues[key]
})
}
async function loadLookup(kind: ReservationV4LookupKind): Promise<void> {
const state = lookupStates[kind]
const requestVersion = ++lookupRequestVersions[kind]
state.loading = true
state.error = ''
try {
const result = await lookupFetcher(kind)({
...(props.hotelId ? { hotel_id: props.hotelId } : {}),
page_num: 1,
page_size: 100,
})
if (requestVersion === lookupRequestVersions[kind]) {
state.result = {
...result,
items: result.items ?? [],
warnings: result.warnings ?? [],
}
}
} catch {
if (requestVersion === lookupRequestVersions[kind]) {
state.error = t('taskV4.lookup.error')
}
} finally {
if (requestVersion === lookupRequestVersions[kind]) {
state.loading = false
}
}
}
function lookupFetcher(kind: ReservationV4LookupKind) {
switch (kind) {
case 'ACCOUNT':
return fetchReservationV4AccountLookups
case 'ROOM_TYPE':
return fetchReservationV4RoomTypeLookups
case 'RATE_CODE':
return fetchReservationV4RateCodeLookups
}
}
function clearInvalidLoadedCatalogValues(): void {
const nextValues = { ...props.modelValue }
let changed = false
props.fields.forEach((field) => {
if (!isSelectField(field) || !isReservationV4EditableField(field)) {
return
}
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
const result = kind ? lookupStates[kind].result : null
if (!kind || !result || !isFullyLoadedCatalog(result)) {
return
}
const key = reservationV4FieldKey(field)
const currentValue = String(readV4FieldValue(field, nextValues) || '')
if (!currentValue) {
return
}
if (result.items.some((item) => item.code === currentValue)) {
delete invalidLookupValues[key]
return
}
invalidLookupValues[key] = currentValue
nextValues[key] = ''
changed = true
})
if (changed) {
emit('update:modelValue', nextValues)
}
}
function isFullyLoadedCatalog(result: ReservationV4CatalogLookupResult): boolean {
return result.items.length >= result.page.total
}
</script>
<style scoped>
@@ -255,6 +547,16 @@ function normalizeControlType(value: string | null): string {
font-size: 12px;
}
.v4-field__hint--warning {
color: var(--th-color-warning);
font-weight: 700;
}
.v4-field__hint--error {
color: var(--th-color-danger);
font-weight: 700;
}
.v4-field__error {
color: var(--th-color-danger);
font-size: 12px;