接入前端真实接口并标记邮件HTML清洗
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user