接入前端真实接口并标记邮件HTML清洗

This commit is contained in:
andy committed 2026-07-08 23:37:03 +08:00
1 parent c83935a26c
commit a0cb3a05d7
25 files changed
+2581 -157

No files matched your search

@@ -31,7 +31,7 @@
{{ t('task.viewOrder') }} {{ detail.order_id }}
</RouterLink>
<span
v-if="!detail.source_message_id || !detail.external_conversation_id"
v-if="!detail.source_message_id"
class="task-link task-link--disabled"
>
{{ t('task.viewConversation') }} · {{ t('common.endpointMissing') }}
@@ -108,6 +108,7 @@
v-model="fieldValues"
:fields="detail.fields"
:read-only="detail.availability.read_only"
:validation-errors="validationErrorMessages"
/>
<footer class="task-actions">
@@ -173,7 +174,12 @@ import type {
ReservationTaskAuditLogResult,
ReservationTaskDetailResult,
} from '@/types/reservation'
import { buildInitialFieldValues } from '@/utils/reservationFieldRules'
import {
buildEditableFieldValues,
buildInitialFieldValues,
validateReservationFieldValues,
type ReservationFieldValidationErrors,
} from '@/utils/reservationFieldRules'
import { formatReservationDateTime } from '@/utils/reservationFormat'
const props = defineProps<{
@@ -188,10 +194,13 @@ const notice = ref('')
const detail = ref<ReservationTaskDetailResult | null>(null)
const audits = ref<ReservationTaskAuditLogResult[]>([])
const fieldValues = ref<ReservationRecord>({})
const validationErrors = ref<ReservationFieldValidationErrors>({})
const validationSummaryActive = ref(false)
const mutationBusy = ref<'save' | 'confirm' | null>(null)
const canSave = computed(() => Boolean(detail.value?.availability.editable))
const canConfirm = computed(() => Boolean(detail.value?.availability.confirmable))
const validationErrorMessages = computed(() => toValidationErrorMessages(validationErrors.value))
watch(
() => props.taskId,
@@ -201,6 +210,14 @@ watch(
{ immediate: true },
)
watch(
fieldValues,
() => {
refreshValidationAfterFieldChange()
},
{ deep: true },
)
async function loadTask(taskId: string): Promise<void> {
loading.value = true
errorMessage.value = ''
@@ -214,6 +231,8 @@ async function loadTask(taskId: string): Promise<void> {
detail.value = nextDetail
audits.value = nextAudits.items
fieldValues.value = buildInitialFieldValues(nextDetail.fields, nextDetail.draft_payload ?? nextDetail.confirmed_payload)
validationErrors.value = {}
validationSummaryActive.value = false
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
@@ -236,10 +255,14 @@ async function mutateTaskPayload(action: 'save' | 'confirm'): Promise<void> {
const taskId = detail.value.task_id
mutationBusy.value = action
actionErrorMessage.value = ''
validationSummaryActive.value = false
notice.value = ''
try {
if (action === 'confirm' && !validateBeforeConfirm(detail.value)) {
return
}
const request = {
field_values: fieldValues.value,
field_values: buildEditableFieldValues(detail.value.fields, fieldValues.value, detail.value.availability.read_only),
}
const result =
action === 'save' ? await saveReservationTaskDraft(taskId, request) : await confirmReservationTask(taskId, request)
@@ -259,6 +282,42 @@ async function mutateTaskPayload(action: 'save' | 'confirm'): Promise<void> {
}
}
function validateBeforeConfirm(taskDetail: ReservationTaskDetailResult): boolean {
const nextErrors = validateReservationFieldValues(taskDetail.fields, fieldValues.value)
validationErrors.value = nextErrors
if (hasValidationErrors(nextErrors)) {
actionErrorMessage.value = t('task.validationFailed')
validationSummaryActive.value = true
return false
}
return true
}
function refreshValidationAfterFieldChange(): void {
if (!detail.value || (!hasValidationErrors(validationErrors.value) && !validationSummaryActive.value)) {
return
}
const nextErrors = validateReservationFieldValues(detail.value.fields, fieldValues.value)
validationErrors.value = nextErrors
if (!hasValidationErrors(nextErrors) && validationSummaryActive.value) {
actionErrorMessage.value = ''
validationSummaryActive.value = false
}
}
function hasValidationErrors(errors: ReservationFieldValidationErrors): boolean {
return Object.keys(errors).length > 0
}
function toValidationErrorMessages(errors: ReservationFieldValidationErrors): Record<string, string> {
return Object.fromEntries(
Object.entries(errors).map(([fieldPath, error]) => [
fieldPath,
t(`task.validation.${error.code}`, { field: error.field_name }),
]),
)
}
async function refreshAudits(taskId: string): Promise<void> {
const nextAudits = await fetchReservationTaskAudits(taskId)
audits.value = nextAudits.items
@@ -22,6 +22,7 @@
<select
v-if="isEditable(field) && isSelectField(field)"
class="field-control"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@change="updateField(field, $event)"
>
@@ -38,6 +39,7 @@
v-else-if="isEditable(field) && isDateField(field)"
class="field-control"
type="date"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
>
@@ -46,6 +48,7 @@
v-else-if="isEditable(field) && isNumberField(field)"
class="field-control"
type="number"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
>
@@ -53,6 +56,7 @@
<textarea
v-else-if="isEditable(field)"
class="field-control field-control--textarea"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
rows="3"
@input="updateField(field, $event)"
@@ -87,6 +91,10 @@
v-if="field.notes"
class="field-note"
>{{ field.notes }}</small>
<small
v-if="fieldError(field)"
class="field-error"
>{{ fieldError(field) }}</small>
</label>
</div>
</section>
@@ -115,6 +123,7 @@ const props = defineProps<{
fields: ReservationTaskFieldResult[]
modelValue: ReservationRecord
readOnly: boolean
validationErrors?: Record<string, string>
}>()
const emit = defineEmits<{
@@ -123,7 +132,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const fieldGroups = computed(() => groupReservationFields(props.fields, t('task.fieldAreaFallback')))
const fieldGroups = computed(() => groupReservationFields(props.fields, t('task.fieldAreaFallback'), props.modelValue))
function fieldValue(field: ReservationTaskFieldResult): string | number | readonly string[] {
const value = props.modelValue[field.field_path] ?? field.value ?? ''
@@ -143,6 +152,10 @@ function isEditable(field: ReservationTaskFieldResult): boolean {
return isReservationFieldEditable(field, props.readOnly)
}
function fieldError(field: ReservationTaskFieldResult): string {
return props.validationErrors?.[field.field_path] ?? ''
}
function updateField(field: ReservationTaskFieldResult, event: Event): void {
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
emit('update:modelValue', {
@@ -241,6 +254,18 @@ function updateField(field: ReservationTaskFieldResult, event: Event): void {
line-height: 1.55;
}
.field-error {
color: var(--th-color-danger);
font-size: 12px;
font-weight: 800;
line-height: 1.55;
}
.field-control[aria-invalid='true'] {
border-color: var(--th-color-danger);
box-shadow: 0 0 0 1px var(--th-color-danger);
}
@media (max-width: 760px) {
.field-grid {
grid-template-columns: 1fr;
@@ -1,27 +1,50 @@
<template>
<div class="task-queue">
<button
<article
v-for="task in items"
:key="task.task_id"
class="task-row"
:class="{ 'task-row--active': task.task_id === activeTaskId }"
type="button"
@click="$emit('openTask', task.task_id)"
>
<span class="task-row__index">{{ task.queue_sequence ?? '-' }}</span>
<span class="task-row__main">
<strong>{{ task.card_name ?? task.task_type }}</strong>
<small>{{ task.task_type }} / {{ task.task_subtype ?? '-' }}</small>
<em v-if="!task.source_message_id || !task.external_conversation_id">
<button
class="task-row__select"
type="button"
@click="$emit('openTask', task.task_id)"
>
<span class="task-row__index">{{ task.queue_sequence ?? '-' }}</span>
<span class="task-row__main">
<strong>{{ task.card_name ?? task.task_type }}</strong>
<small>{{ task.task_type }} / {{ task.task_subtype ?? '-' }}</small>
<em :class="{ 'task-row__reason--blocked': !task.can_process }">
{{ task.can_process ? t('taskList.canProcess') : t('taskList.cannotProcess') }}
<template v-if="!task.can_process && task.readonly_reason_code">
· {{ task.readonly_reason_code }}
</template>
</em>
</span>
<ReservationStatusBadge :status="task.task_status" />
</button>
<div class="task-row__actions">
<span
v-if="!task.source_message_id"
class="task-row__pending"
>
{{ t('common.endpointMissing') }}
</em>
</span>
<ReservationStatusBadge :status="task.task_status" />
</button>
</span>
<RouterLink
v-else
class="task-row__link"
:to="`/reservation/source-messages/${task.source_message_id}/conversation`"
>
{{ t('taskList.viewConversation') }}
</RouterLink>
</div>
</article>
</div>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
@@ -46,17 +69,24 @@ const { t } = useI18n()
}
.task-row {
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-md);
background: var(--th-color-white);
color: inherit;
padding: 10px;
}
.task-row__select {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
width: 100%;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-md);
background: var(--th-color-white);
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
padding: 10px;
padding: 0;
text-align: left;
}
@@ -85,7 +115,8 @@ const { t } = useI18n()
}
.task-row__main strong,
.task-row__main small {
.task-row__main small,
.task-row__main em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -102,9 +133,31 @@ const { t } = useI18n()
}
.task-row__main em {
color: var(--th-color-slate-500);
color: var(--th-color-success);
font-size: 11px;
font-style: normal;
font-weight: 800;
}
.task-row__reason--blocked {
color: var(--th-color-warning);
}
.task-row__actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.task-row__link,
.task-row__pending {
color: var(--th-color-slate-500);
font-size: 11px;
font-weight: 800;
text-decoration: none;
}
.task-row__link {
color: var(--th-color-blue-600);
}
</style>