接入前端P0页面真实接口

This commit is contained in:
andy committed 2026-07-08 16:20:06 +08:00
1 parent 5c5b8e33b0
commit 66a613d6cd
45 files changed
+8719

No files matched your search

@@ -0,0 +1,474 @@
<template>
<section class="th-section task-detail-panel">
<div
v-if="loading"
class="task-state"
>
Loading...
</div>
<div
v-else-if="errorMessage"
class="task-state task-state--error"
>
{{ errorMessage }}
</div>
<template v-else-if="detail">
<header class="task-detail-header">
<div>
<p class="task-eyebrow">
{{ t(`cardType.${detail.task_card_type}`, detail.task_card_type) }}
</p>
<h1>{{ t('task.title') }} {{ detail.task_id }}</h1>
<span class="task-contract">
{{ t('task.fieldContract') }}: {{ detail.field_contract_version }}
</span>
<div class="task-links">
<RouterLink
class="task-link"
:to="`/reservation/orders/${detail.order_id}`"
>
{{ t('task.viewOrder') }} {{ detail.order_id }}
</RouterLink>
<span
v-if="!detail.source_message_id || !detail.external_conversation_id"
class="task-link task-link--disabled"
>
{{ t('task.viewConversation') }} · {{ t('common.endpointMissing') }}
</span>
<RouterLink
v-else
class="task-link"
:to="`/reservation/source-messages/${detail.source_message_id}/conversation`"
>
{{ t('task.viewConversation') }}
</RouterLink>
</div>
</div>
<ReservationStatusBadge :status="detail.task_status" />
</header>
<section class="task-evidence">
<h2>{{ t('task.evidence') }}</h2>
<dl>
<div>
<dt>{{ t('task.sourceMessageId') }}</dt>
<dd class="th-code">
{{ detail.source_message_id || '-' }}
</dd>
</div>
<div>
<dt>{{ t('task.sourceSubject') }}</dt>
<dd>
{{ detail.source_subject ?? t('task.sourceSummaryPending') }}
</dd>
</div>
<div>
<dt>{{ t('task.sourceSender') }}</dt>
<dd>{{ detail.source_sender_summary ?? '-' }}</dd>
</div>
<div>
<dt>{{ t('task.sourceReceivedAt') }}</dt>
<dd>{{ formatReservationDateTime(detail.source_received_at) }}</dd>
</div>
</dl>
</section>
<div
v-if="detail.availability.blocked"
class="task-alert"
>
<i
class="pi pi-lock"
aria-hidden="true"
/>
{{ detail.availability.blocked_reason ?? t('task.blocked') }}
</div>
<div
v-if="notice"
class="task-notice"
>
{{ notice }}
</div>
<div
v-if="actionErrorMessage"
class="task-alert"
>
<i
class="pi pi-exclamation-triangle"
aria-hidden="true"
/>
{{ actionErrorMessage }}
</div>
<div class="task-detail-body">
<div class="task-card-main">
<ReservationTaskFieldRenderer
v-model="fieldValues"
:fields="detail.fields"
:read-only="detail.availability.read_only"
/>
<footer class="task-actions">
<button
type="button"
class="secondary-button"
:disabled="!canSave || mutationBusy !== null"
@click="saveDraft"
>
<i
class="pi pi-save"
aria-hidden="true"
/>
{{ t('task.saveDraft') }}
</button>
<button
type="button"
class="primary-button"
:disabled="!canConfirm || mutationBusy !== null"
@click="confirmTask"
>
<i
class="pi pi-check"
aria-hidden="true"
/>
{{ t('task.confirm') }}
</button>
</footer>
</div>
<aside class="task-side-stack">
<ReservationOperaPanel
:task-id="detail.task_id"
:operations="detail.opera_operations"
:executable="detail.availability.executable"
@operation-updated="replaceOperation"
/>
<ReservationAuditTimeline :items="audits" />
</aside>
</div>
</template>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ReservationAuditTimeline from '@/components/reservation/ReservationAuditTimeline.vue'
import ReservationOperaPanel from '@/components/reservation/ReservationOperaPanel.vue'
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
import ReservationTaskFieldRenderer from '@/components/reservation/ReservationTaskFieldRenderer.vue'
import {
confirmReservationTask,
fetchReservationTaskAudits,
fetchReservationTaskDetail,
saveReservationTaskDraft,
} from '@/services/reservationService'
import type {
ReservationOperaOperationResult,
ReservationRecord,
ReservationTaskAuditLogResult,
ReservationTaskDetailResult,
} from '@/types/reservation'
import { buildInitialFieldValues } from '@/utils/reservationFieldRules'
import { formatReservationDateTime } from '@/utils/reservationFormat'
const props = defineProps<{
taskId: string
}>()
const { t } = useI18n()
const loading = ref(false)
const errorMessage = ref('')
const actionErrorMessage = ref('')
const notice = ref('')
const detail = ref<ReservationTaskDetailResult | null>(null)
const audits = ref<ReservationTaskAuditLogResult[]>([])
const fieldValues = ref<ReservationRecord>({})
const mutationBusy = ref<'save' | 'confirm' | null>(null)
const canSave = computed(() => Boolean(detail.value?.availability.editable))
const canConfirm = computed(() => Boolean(detail.value?.availability.confirmable))
watch(
() => props.taskId,
(taskId) => {
void loadTask(taskId)
},
{ immediate: true },
)
async function loadTask(taskId: string): Promise<void> {
loading.value = true
errorMessage.value = ''
actionErrorMessage.value = ''
notice.value = ''
try {
const [nextDetail, nextAudits] = await Promise.all([
fetchReservationTaskDetail(taskId),
fetchReservationTaskAudits(taskId),
])
detail.value = nextDetail
audits.value = nextAudits.items
fieldValues.value = buildInitialFieldValues(nextDetail.fields, nextDetail.draft_payload ?? nextDetail.confirmed_payload)
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
loading.value = false
}
}
async function saveDraft(): Promise<void> {
await mutateTaskPayload('save')
}
async function confirmTask(): Promise<void> {
await mutateTaskPayload('confirm')
}
async function mutateTaskPayload(action: 'save' | 'confirm'): Promise<void> {
if (!detail.value) {
return
}
const taskId = detail.value.task_id
mutationBusy.value = action
actionErrorMessage.value = ''
notice.value = ''
try {
const request = {
field_values: fieldValues.value,
}
const result =
action === 'save' ? await saveReservationTaskDraft(taskId, request) : await confirmReservationTask(taskId, request)
detail.value = {
...detail.value,
task_status: result.task_status,
draft_payload: result.draft_payload,
confirmed_payload: result.confirmed_payload,
opera_operations: result.opera_operations,
}
await refreshAudits(taskId)
notice.value = action === 'save' ? t('task.savedLocally') : t('task.confirmedLocally')
} catch (error) {
actionErrorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
mutationBusy.value = null
}
}
async function refreshAudits(taskId: string): Promise<void> {
const nextAudits = await fetchReservationTaskAudits(taskId)
audits.value = nextAudits.items
}
function replaceOperation(operation: ReservationOperaOperationResult): void {
if (!detail.value) {
return
}
detail.value = {
...detail.value,
opera_operations: detail.value.opera_operations.map((item) =>
item.operation_id === operation.operation_id ? operation : item,
),
}
}
</script>
<style scoped>
.task-detail-panel {
display: grid;
gap: 18px;
padding: 20px;
}
.task-state {
min-height: 220px;
display: grid;
place-items: center;
color: var(--th-color-slate-500);
font-weight: 700;
}
.task-state--error {
color: var(--th-color-danger);
}
.task-detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.task-detail-header h1,
.task-eyebrow {
margin: 0;
}
.task-eyebrow {
color: var(--th-color-blue-600);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.task-detail-header h1 {
margin-top: 5px;
color: var(--th-color-slate-900);
font-size: 22px;
}
.task-contract {
display: inline-block;
margin-top: 7px;
color: var(--th-color-slate-500);
font-size: 12px;
}
.task-links {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.task-link {
color: var(--th-color-blue-600);
font-size: 12px;
font-weight: 800;
}
.task-link--disabled {
color: var(--th-color-slate-500);
}
.task-evidence {
display: grid;
gap: 12px;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-md);
padding: 16px;
}
.task-evidence h2,
.task-evidence dl {
margin: 0;
}
.task-evidence h2 {
color: var(--th-color-slate-900);
font-size: 15px;
}
.task-evidence dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.task-evidence dt {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 700;
}
.task-evidence dd {
margin: 5px 0 0;
color: var(--th-color-slate-900);
font-size: 13px;
overflow-wrap: anywhere;
}
.task-alert,
.task-notice {
display: flex;
align-items: center;
gap: 8px;
border-radius: var(--th-radius-sm);
padding: 10px 12px;
font-size: 13px;
line-height: 1.5;
}
.task-alert {
background: var(--th-color-danger-bg);
color: var(--th-color-danger);
}
.task-notice {
background: var(--th-color-success-bg);
color: var(--th-color-success);
}
.task-detail-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 18px;
align-items: start;
}
.task-card-main,
.task-side-stack {
display: grid;
gap: 16px;
}
.task-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid var(--th-color-slate-200);
padding-top: 16px;
}
.primary-button,
.secondary-button {
display: inline-flex;
min-height: 36px;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: var(--th-radius-sm);
padding: 8px 14px;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.primary-button {
border: 1px solid var(--th-color-blue-600);
background: var(--th-color-blue-600);
color: var(--th-color-white);
}
.secondary-button {
border: 1px solid var(--th-color-slate-200);
background: var(--th-color-white);
color: var(--th-color-slate-900);
}
.primary-button:disabled,
.secondary-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
@media (max-width: 1040px) {
.task-detail-body {
grid-template-columns: 1fr;
}
.task-evidence dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.task-evidence dl {
grid-template-columns: 1fr;
}
}
</style>