This commit is contained in:
2026-09-14 18:34:34 +08:00
parent 25825685c3
commit 66f49b97e5
40 changed files with 472 additions and 304 deletions
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { Eye, Save } from '@lucide/vue'
@@ -147,7 +148,7 @@ watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) v
</script>
<template>
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form admin-density density-form">
<div class="relation-note"><span>一张已放舱订舱对应一张有效拖车单;仓库和提单号由订舱冻结资料带入,货柜资料在货柜与预装流程维护,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
<el-alert v-if="relationWarning" type="warning" :closable="false" show-icon :title="relationWarning" class="relation-alert" />
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
@@ -193,18 +194,18 @@ watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) v
</template>
<style scoped>
.truck-form { padding-bottom: 4px; }
.relation-note { display:flex; min-height:40px; align-items:center; justify-content:space-between; gap:16px; margin:0 0 8px; padding:6px 8px 6px 10px; border-left:3px solid var(--cc-primary); background:var(--el-fill-color-extra-light); color:var(--cc-muted); font-size:12px; line-height:1.5; }
.truck-form { padding-bottom: var(--admin-space-1); }
.relation-note { display:flex; min-height:40px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); margin:0 0 var(--admin-space-3); padding:var(--admin-space-2) var(--admin-space-3) var(--admin-space-2) var(--admin-space-4); border-left:3px solid var(--cc-primary); background:var(--el-fill-color-extra-light); color:var(--cc-muted); font-size:var(--admin-font-caption); line-height:1.5; }
.relation-note > span { min-width:0; }
.relation-note__actions { display:flex; flex:0 0 auto; align-items:center; }
.relation-note__actions :deep(.el-button) { margin-left:0; }
.relation-alert { margin-bottom: 16px; }
.form-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
:deep(.el-divider) { margin: 14px 0 10px; }
:deep(.el-divider__text) { color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; }
.relation-alert { margin-bottom: var(--admin-space-5); }
.form-actions { display: flex; justify-content: flex-end; gap: var(--admin-space-3); flex-wrap: wrap; }
:deep(.el-divider) { margin: var(--admin-space-5) 0 var(--admin-space-4); }
:deep(.el-divider__text) { color: var(--el-text-color-primary); font-size: var(--admin-font-section); font-weight: 600; }
:deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; }
:deep(.el-select__selected-item), :deep(.el-select-dropdown__item) { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.field-error,.field-hint { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:4px;color:var(--cc-muted);font-size:12px;line-height:20px }.field-error{color:var(--el-color-danger)}
.field-error,.field-hint { display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-3);margin-top:var(--admin-space-1);color:var(--cc-muted);font-size:var(--admin-font-caption);line-height:20px }.field-error{color:var(--el-color-danger)}
@media (min-width: 1200px) {
.truck-form :deep(.el-col-sm-12), .truck-form :deep(.el-col-sm-8) { max-width:25%; flex:0 0 25%; }
.truck-form :deep(.relation-column), .truck-form :deep(.wide-column) { max-width:50%; flex-basis:50%; }
@@ -21,7 +21,7 @@ describe('TruckAttachmentDialog interaction governance', () => {
expect(source).toContain('上传并关联')
expect(source).toContain('deleteTruckAppointmentAttachment')
expect(source).toContain("confirmButtonText: '确认', cancelButtonText: '取消'")
expect(source).toContain('<Trash2 :size="15" />删除')
expect(source).toContain('@click="remove(item)">删除</el-button>')
expect(source).toContain('size="small" type="danger" plain')
expect(source).not.toContain(' text type="danger"')
})
@@ -1,7 +1,8 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { FileUp, Trash2 } from '@lucide/vue'
import { FileUp } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { deleteTruckAppointmentAttachment, uploadTruckAppointmentAttachment } from '../api/platformTrucking'
@@ -16,23 +17,23 @@ const attachmentTypeLabels: Record<TruckAttachmentType, string> = { DISPATCH_ORD
function attachmentTypeLabel(value: string | null | undefined) { return value && value in attachmentTypeLabels ? attachmentTypeLabels[value as TruckAttachmentType] : '业务附件' }
function selectFile(upload: { raw?: File }) { selectedFile.value = upload.raw || null }
async function upload() { if (busy.value || !props.appointmentId || !selectedFile.value || !props.canManage) return; busy.value = true; error.value = null; try { await uploadTruckAppointmentAttachment(props.appointmentId, selectedFile.value, selectedType.value); selectedFile.value = null; emit('changed'); ElMessage.success('附件上传成功') } catch (cause) { error.value = getApiErrorDetails(cause, '附件上传失败,请稍后重试') } finally { busy.value = false } }
async function remove(item: TruckAttachmentReference) { if (busy.value || !props.appointmentId || !item.id || !props.canManage) return; busy.value = true; error.value = null; try { await ElMessageBox.confirm('确认删除该附件引用?', '删除确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); await deleteTruckAppointmentAttachment(props.appointmentId, item.id); emit('changed'); ElMessage.success('附件引用已删除') } catch (cause) { if (cause === 'cancel' || cause === 'close') return; error.value = getApiErrorDetails(cause, '附件删除失败,请稍后重试') } finally { busy.value = false } }
async function remove(item: TruckAttachmentReference) { if (busy.value || !props.appointmentId || !item.id || !props.canManage) return; busy.value = true; error.value = null; try { await ElMessageBox.confirm('确认删除该附件引用?', '删除确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); await deleteTruckAppointmentAttachment(props.appointmentId, item.id); emit('changed'); ElMessage.success('附件引用已删除') } catch (cause) { if (cause === 'cancel' || cause === 'close') return; error.value = getApiErrorDetails(cause, '附件删除失败,请稍后重试') } finally { busy.value = false } }
</script>
<template>
<el-dialog :model-value="props.modelValue" title="预约附件" width="min(640px, 94vw)" @update:model-value="emit('update:modelValue', $event)">
<el-dialog class="admin-density-dialog" :model-value="props.modelValue" title="预约附件" width="min(640px, 94vw)" @update:model-value="emit('update:modelValue', $event)">
<el-alert v-if="error" type="error" :closable="true" show-icon @close="error = null"><template #title>{{ error.message }}</template><ApiErrorMeta :details="error" /></el-alert>
<div v-if="props.canManage" class="upload-panel"><el-select v-model="selectedType" aria-label="附件类型" style="width:160px"><el-option v-for="(label, type) in attachmentTypeLabels" :key="type" :label="label" :value="type" /></el-select><el-upload :auto-upload="false" :limit="1" :show-file-list="true" :on-change="selectFile" :on-remove="() => { selectedFile = null }"><el-button><FileUp :size="15" />选择附件</el-button></el-upload><el-button type="primary" :loading="busy" :disabled="!selectedFile" @click="upload">上传并关联</el-button></div>
<el-empty v-if="attachments.length === 0" description="暂无预约附件" :image-size="72" />
<div v-for="item in attachments" :key="item.fileId" class="attachment-row">
<div><strong>{{ item.fileName || '未命名文件' }}</strong><small>{{ attachmentTypeLabel(item.attachmentType) }}{{ item.uploadedAt ? ` · ${item.uploadedAt}` : '' }}</small></div>
<el-button v-if="props.appointmentId && props.canManage && item.id" size="small" type="danger" plain :loading="busy" @click="remove(item)"><Trash2 :size="15" />删除</el-button>
<el-button data-tone="danger" class="density-action" v-if="props.appointmentId && props.canManage && item.id" size="small" type="danger" plain :loading="busy" @click="remove(item)">删除</el-button>
</div>
<template #footer><el-button type="primary" @click="emit('update:modelValue', false)">关闭</el-button></template>
</el-dialog>
</template>
<style scoped>
.upload-panel { display:flex; align-items:center; flex-wrap:wrap; gap:8px; margin-bottom:16px; }
.attachment-row { display:flex; justify-content:space-between; gap:12px; margin:14px 0; padding:12px; border:1px solid var(--el-border-color-lighter); border-radius:6px; overflow-wrap:anywhere; }.attachment-row small { display:block; margin-top:4px; color:var(--el-text-color-secondary); }
.upload-panel { display:flex; align-items:center; flex-wrap:wrap; gap:var(--admin-space-3); margin-bottom:var(--admin-space-5); }
.attachment-row { display:flex; justify-content:space-between; gap:var(--admin-space-5); overflow-wrap:anywhere; margin:0; padding:var(--admin-space-3) 0; border:0; border-bottom:1px solid var(--cc-border); border-radius:0; }.attachment-row small { display:block; margin-top:var(--admin-space-1); color:var(--el-text-color-secondary); }
.attachment-row>div{min-width:0}.attachment-row strong{overflow-wrap:anywhere}
@media (max-width: 560px) { .attachment-row { flex-direction:column; } }
@media (max-width: 560px) { .attachment-row { flex-direction:column; margin:0; padding:var(--admin-space-3) 0; border:0; border-bottom:1px solid var(--cc-border); border-radius:0; } }
</style>
@@ -37,10 +37,10 @@ describe('TruckAppointmentCreateView booking-bound creation contract', () => {
expect(source).toContain('serial !== lookupSerial')
})
it('places the return action beside the relation note instead of in a separate header row', () => {
expect(source).toContain('<template #relation-note-actions>')
it('places lightweight return navigation beside the new appointment heading', () => {
expect(source).toContain('class="density-back"')
expect(source).toContain('<ArrowLeft :size="16" />返回列表')
expect(source).not.toContain('<template #header>')
expect(source).toContain('<template #header><header class="create-heading surface-panel">')
expect(source).not.toContain('class="editor-actions"')
})
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
@@ -68,14 +69,14 @@ watch(() => form.oceanBookingId, (value) => { void lookupExistingAppointment(Str
defineExpose({ form, submit, lookupExistingAppointment, existingAppointment, existingLookupError, checkedOceanBookingId })
</script>
<template>
<AdminPage as="div" class="truck-editor-page">
<DetailPageLayout aria-label="新增拖车预约">
<AdminPage as="div" class="truck-editor-page admin-density">
<DetailPageLayout aria-label="新增拖车预约"><template #header><header class="create-heading surface-panel"><el-button link class="density-back" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button><h1>新增拖车预约</h1></header></template>
<LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="existingAppointment" type="warning" :closable="false" show-icon><template #title>该订舱已有拖车单 {{ existingAppointment.appointmentNo || existingAppointment.id }}({{ truckAppointmentStatusLabel(existingAppointment.status) }})</template><el-button type="primary" link :loading="navigatingToExisting" @click="openExisting">{{ existingCanEdit ? '继续编辑' : '查看拖车单' }}</el-button></el-alert>
<LoadErrorState v-if="existingLookupError" title="已有拖车单查询失败" :message="existingLookupError.message" compact @retry="lookupExistingAppointment(String(form.oceanBookingId || ''))"><template #details><ApiErrorMeta :details="existingLookupError" /></template></LoadErrorState>
<section class="surface-panel form-panel"><TruckAppointmentForm :model-value="form" :submitting="saving" :submit-blocked="submitBlocked" @update:model-value="updateForm" @submit="submit" @attachments="attachmentOpen = true"><template #relation-note-actions><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></template></TruckAppointmentForm></section>
<section class="surface-panel form-panel"><TruckAppointmentForm :model-value="form" :submitting="saving" :submit-blocked="submitBlocked" @update:model-value="updateForm" @submit="submit" @attachments="attachmentOpen = true"></TruckAppointmentForm></section>
<TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" />
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>.truck-editor-page { min-width: 0; }.form-panel { padding:24px; } @media (max-width:720px) { .form-panel { padding:16px; } }</style>
<style scoped>.truck-editor-page { min-width: 0; }.form-panel { padding:var(--admin-space-6); } @media (max-width:720px) { .form-panel { padding:16px; } } .create-heading{display:flex;align-items:center;gap:var(--admin-space-5);padding:var(--admin-space-3) var(--admin-space-5)} .create-heading h1{margin:0;font-size:var(--admin-font-title)}</style>
@@ -19,9 +19,9 @@ describe('TruckAppointmentDetailView status contract', () => {
})
it('spaces independent form and history sections without nested overview cards', () => {
expect(source).toContain('.truck-detail-page :deep(.detail-page-layout__content) { display:grid; min-width:0; gap:16px; }')
expect(source).toContain('.form-panel { padding:16px; }')
expect(source).toContain('.history-section { padding:16px; }')
expect(source).toContain('.truck-detail-page :deep(.detail-page-layout__content) { display:grid; min-width:0; gap:var(--admin-space-5); }')
expect(source).toContain('.form-panel { padding:var(--admin-space-5); }')
expect(source).toContain('.history-section { padding:var(--admin-space-5); }')
})
it('matches the backend edit lifecycle and avoids a self-transition action', () => {
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -54,7 +55,7 @@ const dispatchBlockReason = computed(() => {
return ''
})
async function confirmStatusChange(status: TruckAppointmentStatus) {
await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
}
async function changeStatus(status: TruckAppointmentStatus) {
if (!resource.value || status === resource.value.status || statusSaving.value) return
@@ -71,12 +72,12 @@ onActivated(() => {
})
</script>
<template>
<AdminPage as="div" class="truck-detail-page">
<AdminPage as="div" class="truck-detail-page admin-density">
<DetailPageLayout aria-label="拖车预约详情">
<template #header>
<section class="truck-overview surface-panel" aria-label="拖车预约概览">
<div class="truck-overview__heading">
<el-button link type="primary" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
<el-button link class="density-back" type="primary" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
<div class="truck-overview__identity"><h1>{{ resource?.appointmentNo || '拖车预约详情' }}</h1><p>{{ resource ? [resource.oceanBookingNo, resource.billOfLadingNo, resource.orderNo].filter(Boolean).join(' · ') || '订舱信息缺失' : '查看拖车预约详情' }}</p></div>
<span v-if="resource" class="truck-overview__status">当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span>
<div v-if="resource && canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" size="small" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div>
@@ -92,7 +93,7 @@ onActivated(() => {
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="resource">
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
<section class="surface-panel history-section"><h3>状态历史</h3><table class="density-table truck-history-table"><thead><tr><th>时间</th><th>事件</th><th>状态变化</th><th>操作人</th><th>备注</th></tr></thead><tbody><tr v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><td>{{ item.createdAt || item.occurredAt || '—' }}</td><td>{{ truckAppointmentActionLabel(item.action) }}</td><td>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</td><td>{{ item.operatorName || '系统' }}</td><td>{{ item.reason || '—' }}</td></tr><tr v-if="!history.length"><td colspan="5">暂无状态历史</td></tr></tbody></table></section>
<TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" />
</template>
</DetailPageLayout>
@@ -100,11 +101,15 @@ onActivated(() => {
</template>
<style scoped>
.truck-overview { overflow:hidden; }
.truck-overview__heading { display:grid; grid-template-columns:auto minmax(260px, 1fr) auto auto; align-items:center; gap:12px 18px; padding:12px 16px; }
.truck-overview__identity { min-width:0; }.truck-overview__identity h1 { margin:0; overflow:hidden; color:var(--cc-text); font-size:18px; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }.truck-overview__identity p { margin:2px 0 0; overflow:hidden; color:var(--cc-muted); font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
.truck-overview__status { font-size:13px; white-space:nowrap; }.truck-overview__status strong { margin-left:4px; font-size:15px; }
.status-actions { display:flex; flex-wrap:wrap; gap:8px; }.status-actions :deep(.el-button) { margin-left:0; }
.relation-snapshot { margin:0; padding:9px 16px; border-top:1px solid var(--cc-border); color:var(--cc-muted); font-size:12px; line-height:1.5; }.relation-snapshot strong { margin-right:4px; color:var(--cc-text); }
.truck-detail-page { min-width:0; }.truck-detail-page :deep(.detail-page-layout__content) { display:grid; min-width:0; gap:16px; }.form-panel { padding:16px; }.history-section { padding:16px; }.history-section h3 { margin:0 0 12px; font-size:15px; }.history-section ol { display:grid; gap:10px; margin:0; padding:0; list-style:none; }.history-section li { display:grid; grid-template-columns:120px minmax(120px, 1fr) minmax(200px, 2fr); gap:12px; padding:9px 12px; border-radius:6px; background:var(--el-fill-color-lighter); }.history-section small { color:var(--cc-muted); overflow-wrap:anywhere; }
@media (max-width: 640px) { .history-section { padding:16px; }.history-section li { grid-template-columns:1fr; gap:4px; } }
.truck-overview__heading { display:grid; grid-template-columns:auto minmax(260px, 1fr) auto auto; align-items:center; gap:var(--admin-space-5) var(--admin-space-6); padding:var(--admin-space-5) var(--admin-space-5); }
.truck-overview__identity { min-width:0; }.truck-overview__identity h1 { margin:0; color:var(--cc-text); font-size:var(--admin-font-title); line-height:1.35; white-space:normal; overflow-wrap:anywhere; }.truck-overview__identity p { margin:2px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption); white-space:normal; overflow-wrap:anywhere; }
.truck-overview__status { font-size:var(--admin-font-body); white-space:nowrap; }.truck-overview__status strong { margin-left:var(--admin-space-1); font-size:var(--admin-font-section); }
.status-actions { display:flex; flex-wrap:wrap; gap:var(--admin-space-3); }.status-actions :deep(.el-button) { margin-left:0; }
.relation-snapshot { margin:0; padding:9px var(--admin-space-5); border-top:1px solid var(--cc-border); color:var(--cc-muted); font-size:var(--admin-font-caption); line-height:1.5; }.relation-snapshot strong { margin-right:var(--admin-space-1); color:var(--cc-text); }
.truck-detail-page { min-width:0; }.truck-detail-page :deep(.detail-page-layout__content) { display:grid; min-width:0; gap:var(--admin-space-5); }.form-panel { padding:var(--admin-space-5); }.history-section { padding:var(--admin-space-5); }.history-section h3 { margin:0 0 var(--admin-space-5); font-size:var(--admin-font-section); }
@media (max-width: 640px) { .history-section { padding:16px; } }
.truck-history-table th:nth-child(1){width:180px}
.truck-history-table th:nth-child(2){width:130px}
.truck-history-table th:nth-child(3){width:240px}
.truck-history-table th:nth-child(4){width:130px}
</style>
@@ -141,8 +141,8 @@ describe('TruckAppointmentsView', () => {
expect(source).toContain('flex:0 0 auto')
expect(source).not.toContain('platform:firstleg:truck-appointment:view')
expect(source).toContain("access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)")
expect(source).toContain('<Eye :size="15" />详情')
expect(source).toContain('<Pencil :size="15" />编辑')
expect(source).toContain('@click="openDetail(row as TruckAppointmentResource)">详情</el-button>')
expect(source).toContain('编辑</el-button>')
expect(source).not.toContain('MoreHorizontal')
})
@@ -1,7 +1,8 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Eye, Pencil, Plus } from '@lucide/vue'
import { Plus } from '@lucide/vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -62,7 +63,7 @@ onMounted(() => { void load() })
function openDetail(row: TruckAppointmentResource, edit = false) { router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: edit ? { edit: '1' } : undefined }) }
</script>
<template>
<AdminPage as="div" class="trucking-page">
<AdminPage as="div" class="trucking-page admin-density">
<ListPageLayout aria-label="拖车预约列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="拖车预约查询" :fields="fields" preference-key="trucking-query" @search="search" /></template>
<template v-if="loadError" #state><LoadErrorState title="拖车预约列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
@@ -73,7 +74,7 @@ function openDetail(row: TruckAppointmentResource, edit = false) { router.push({
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.driverPhone || '—' }}</small></template>
<template #store="{ row }">{{ row.storeName || '—' }}</template>
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)" size="small" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></template>
<template #actions="{ row }"><div class="table-actions density-actions"><el-button class="density-action" size="small" type="primary" plain :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)">详情</el-button><el-button class="density-action" v-if="access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)" size="small" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)">编辑</el-button></div></template>
</DataTable>
</section>
</ListPageLayout>
@@ -83,5 +84,5 @@ function openDetail(row: TruckAppointmentResource, edit = false) { router.push({
.trucking-page { min-width: 0; }
.table-panel { overflow:hidden; }
small { display:block; color:var(--el-text-color-secondary); margin-top:3px; }
.table-actions { display:flex; align-items:center; flex-wrap:nowrap; gap:6px; white-space:nowrap; }.table-actions :deep(.el-button) { flex:0 0 auto; margin-left:0; }
.table-actions { flex-wrap:nowrap; white-space:nowrap; }.table-actions :deep(.el-button) { flex:0 0 auto; margin-left:0; }
</style>