This commit is contained in:
2026-08-10 16:24:54 +08:00
parent e119b48662
commit 820432a381
52 changed files with 1015 additions and 705 deletions
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { Eye, Save } from '@lucide/vue'
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>()
@@ -76,7 +77,7 @@ defineExpose({ validate })
<el-col :xs="24"><el-form-item label="PO"><el-input v-model="draft.poNo" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" /></el-form-item></el-col>
</el-row>
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')">查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" @click="submitForm">保存</el-button></div>
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" @click="submitForm"><Save :size="16" />保存</el-button></div>
</el-form>
</template>
@@ -2,7 +2,11 @@
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
@@ -12,5 +16,14 @@ const router = useRouter(); const saving = ref(false); const attachmentOpen = re
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, plannedLoadingAt: null, payableAmount: '0.00', receivableAmount: '0.00', currency: 'RMB' })
async function submit() { saving.value = true; error.value = null; try { await createTruckAppointment({ ...form }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) } catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') } finally { saving.value = false } }
</script>
<template><main class="business-page truck-editor-page"><div class="editor-actions"><el-button @click="router.push({ name: 'trucking' })">返回列表</el-button></div><section v-if="error" class="surface-panel editor-error"><ApiErrorMeta :details="error" /><span>{{ error.message }}</span></section><section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section><TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" /></main></template>
<style scoped>.truck-editor-page { min-width: 0; }.editor-actions { display:flex; justify-content:flex-end; }.form-panel { padding:24px; }.editor-error { display:flex; align-items:center; gap:12px; padding:14px 18px; color:#b42318; }.editor-error :deep(.api-error-meta) { margin-top:0; order:2; } @media (max-width:720px) { .form-panel { padding:16px; } }</style>
<template>
<AdminPage as="div" class="truck-editor-page">
<DetailPageLayout aria-label="新增拖车预约">
<template #header><div class="editor-actions"><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></div></template>
<LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
<TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" />
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>.truck-editor-page { min-width: 0; }.editor-actions { display:flex; justify-content:flex-end; }.form-panel { padding:24px; } @media (max-width:720px) { .editor-actions { justify-content:flex-start; }.form-panel { padding:16px; } }</style>
@@ -2,8 +2,12 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft } from '@lucide/vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
@@ -27,15 +31,28 @@ async function changeStatus(status: TruckAppointmentStatus) { if (!resource.valu
onMounted(load)
</script>
<template>
<main class="business-page truck-detail-page"><div class="detail-actions"><el-button @click="router.push({ name: 'trucking' })">返回列表</el-button></div>
<LoadErrorState v-if="error && !resource" class="surface-panel" title="详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="resource"><section class="surface-panel status-panel"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabels[resource.status] }}</strong></span><div v-if="canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div></div><ApiErrorMeta v-if="error" :details="error" /></section><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>{{ item.action }}</strong><span>{{ item.fromStatus || '—' }} → {{ item.toStatus || '—' }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section><TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" /></template>
</main>
<AdminPage as="div" class="truck-detail-page">
<DetailPageLayout aria-label="拖车预约详情">
<template #header>
<div class="detail-heading">
<el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
<PageToolbar v-if="resource" class="truck-page-toolbar" as="section" :title="resource.appointmentNo || '拖车预约'" :description="[resource.customerName, resource.orderNo].filter(Boolean).join(' · ')" />
</div>
</template>
<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 status-panel"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabels[resource.status] }}</strong></span><div v-if="canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div></div><ApiErrorMeta v-if="error" :details="error" /></section>
<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>{{ item.action }}</strong><span>{{ item.fromStatus || '—' }} → {{ item.toStatus || '—' }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
<TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" />
</template>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.status-bar { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-bottom:16px; padding:12px; background:var(--el-fill-color-light); border-radius:6px; }
.status-actions { display:flex; flex-wrap:wrap; gap:8px; }.status-actions :deep(.el-button) { margin-left:0; }
.truck-detail-page { min-width:0; }.detail-actions { display:flex; justify-content:flex-end; }.status-panel { padding:12px 16px; }.form-panel { padding:24px; }.history-section { padding:20px; }.history-section h3 { margin:0 0 12px; font-size:15px; }.history-section ol { display:grid; gap:12px; 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:10px 12px; border-radius:6px; background:var(--el-fill-color-lighter); }.history-section small { color:var(--cc-muted); overflow-wrap:anywhere; }
@media (max-width: 720px) { .form-panel { padding:16px; } }
.truck-detail-page { min-width:0; }.detail-heading { display:flex; min-width:0; align-items:flex-start; gap:16px; }.truck-page-toolbar { min-width:0; flex:1; }.status-panel { padding:12px 16px; }.form-panel { padding:24px; }.history-section { padding:20px; }.history-section h3 { margin:0 0 12px; font-size:15px; }.history-section ol { display:grid; gap:12px; 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:10px 12px; border-radius:6px; background:var(--el-fill-color-lighter); }.history-section small { color:var(--cc-muted); overflow-wrap:anywhere; }
@media (max-width: 720px) { .detail-heading { flex-direction:column; }.truck-page-toolbar { width:100%; }.form-panel { padding:16px; } }
@media (max-width: 640px) { .history-section { padding:16px; }.history-section li { grid-template-columns:1fr; gap:4px; } }
</style>
@@ -5,6 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import TruckAppointmentsView from './TruckAppointmentsView.vue'
import { listTruckAppointments } from '../api/platformTrucking'
import type { TruckAppointmentResource } from '../types'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentsView.vue'), 'utf8')
vi.mock('../api/platformTrucking', () => ({
listTruckAppointments: vi.fn(),
@@ -31,4 +35,12 @@ describe('TruckAppointmentsView', () => {
expect(wrapper.text()).toContain('宁波运输')
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
})
it('uses the table toolbar for create and keeps a single empty state', () => {
expect(source).toContain('<template #toolbar-actions>')
expect(source).not.toContain('<template #toolbar-start>')
expect(source).not.toContain('PageToolbar')
expect(source).not.toContain('empty-hint')
expect(source.match(/empty-text=/g)).toHaveLength(1)
})
})
@@ -1,18 +1,20 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Eye, Pencil, Plus, RefreshCw } from '@lucide/vue'
import { Eye, Pencil, 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'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listTruckAppointments } from '../api/platformTrucking'
import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabels, type TruckAppointmentQuery, type TruckAppointmentResource, type TruckAppointmentStatus } from '../types'
const router = useRouter(); const rows = ref<TruckAppointmentResource[]>([]); const loading = ref(false); const loaded = ref(false); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loadError = ref<ApiErrorDetails | null>(null)
const router = useRouter(); const rows = ref<TruckAppointmentResource[]>([]); const loading = ref(false); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loadError = ref<ApiErrorDetails | null>(null)
const access = useAccessPreviewStore()
const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '', containerBookingId: '', containerType: '' }); const applied = ref({ ...filters.value })
const fields = computed<QueryField[]>(() => [
@@ -42,19 +44,19 @@ function statusLabel(value: unknown) {
? truckAppointmentStatusLabels[value as TruckAppointmentStatus]
: String(value || '—')
}
async function load() { loading.value = true; loadError.value = null; try { const result = await listTruckAppointments({ page: page.value, size: pageSize.value, ...applied.value } as TruckAppointmentQuery); rows.value = result.records; total.value = result.total; loaded.value = true } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '拖车预约加载失败,请稍后重试') } finally { loading.value = false } }
async function load() { loading.value = true; loadError.value = null; try { const result = await listTruckAppointments({ page: page.value, size: pageSize.value, ...applied.value } as TruckAppointmentQuery); rows.value = result.records; total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '拖车预约加载失败,请稍后重试') } finally { loading.value = false } }
function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '', containerBookingId: '', containerType: String(model.containerType || '') }; page.value = 1; void load() }
function reset() { filters.value = { keyword: '', status: '', containerBookingId: '', containerType: '' }; search(filters.value) }
onMounted(load)
function openDetail(row: TruckAppointmentResource, edit = false) { router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: edit ? { edit: '1' } : undefined }) }
</script>
<template>
<section class="business-page trucking-page">
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="拖车预约查询" :fields="fields" preference-key="trucking-query" @search="search" @reset="reset" />
<section v-if="loadError" class="surface-panel trucking-error" role="alert"><div><strong>拖车预约列表暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" :loading="loading" @click="load">重新加载</el-button></section>
<section v-else class="surface-panel table-panel" :aria-busy="loading">
<AdminPage as="div" class="trucking-page">
<ListPageLayout aria-label="拖车预约列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="拖车预约查询" :fields="fields" preference-key="trucking-query" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="拖车预约列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="trucking-appointments" aria-label="拖车预约列表" empty-text="暂无符合筛选条件的拖车预约">
<template #toolbar-start><div class="table-heading"><h2>拖车预约列表</h2><span>共 {{ total }} 条</span></div></template>
<template #toolbar-actions><el-button v-if="access.can('platform:firstleg:truck-appointment:create')" type="primary" :disabled="loading" @click="router.push({ name: 'trucking-new' })"><Plus :size="16" />新增拖车</el-button></template>
<template #company="{ row }">{{ row.carrierName || row.carrierId || '—' }}</template>
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.driverPhone || '—' }}</small></template>
@@ -62,12 +64,12 @@ function openDetail(row: TruckAppointmentResource, edit = false) { router.push({
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
<template #actions="{ row }"><div class="table-actions"><el-button link type="primary" :aria-label="`查看 ${row.appointmentNo || row.id}`" @click="openDetail(row as TruckAppointmentResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit')" link type="primary" :aria-label="`编辑 ${row.appointmentNo || row.id}`" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></template>
</DataTable>
</section>
<div v-if="!loading && loaded && rows.length === 0 && !loadError" class="empty-hint"><RefreshCw :size="16" />暂无拖车预约</div>
</section>
</section>
</ListPageLayout>
</AdminPage>
</template>
<style scoped>
.trucking-page { min-width: 0; }
.table-panel { overflow:hidden; }.trucking-error { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:16px; padding:18px 20px; border-color:#fecdca; background:#fffafa; }.trucking-error strong { display:block; }.trucking-error p { margin:4px 0 0; color:var(--cc-muted); font-size:12px; }.table-heading { display:flex; align-items:baseline; gap:10px; }.table-heading h2 { margin:0; font-size:16px; }.table-heading span { color:var(--el-text-color-secondary); font-size:13px; }.empty-hint { display:flex; justify-content:center; gap:8px; color:var(--el-text-color-secondary); padding:24px; }
.table-panel { overflow:hidden; }
small { display:block; color:var(--el-text-color-secondary); margin-top:3px; }
</style>