feat(admin): add phase3 service center pages

This commit is contained in:
2026-08-11 11:26:31 +08:00
parent 9b994040b8
commit c8eb2d75cd
23 changed files with 260 additions and 3 deletions
@@ -0,0 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { createCustomsCase, listCustomsCases, submitCustomsCase } from './platformCustoms'
vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http'); return { ...actual, http: { get: vi.fn(), post: vi.fn() } } })
describe('platformCustoms', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes case ids', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ id: 1, customsNo: 'C1', orderId: 2, inboundId: 3, checklistVersionId: 4, checklistCode: 'US', checklistVersionNo: 'V1', status: 'DRAFT', version: '1' }], page: 1, size: 20, total: 1, pages: 1 } }); const page = await listCustomsCases({ page: 1, size: 20 }); expect(page.records[0]).toMatchObject({ id: '1', orderId: '2', inboundId: '3', checklistVersionId: '4', version: 1 }) }); it('writes with idempotency and version', async () => { const detail = { customsCase: { id: 1, customsNo: 'C1', orderId: 2, inboundId: 3, checklistVersionId: 4, checklistCode: 'US', checklistVersionNo: 'V1', status: 'DRAFT', version: 0 }, documents: [], supplements: [], submissionBatches: [], receipts: [], history: [] }; vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: detail }).mockResolvedValueOnce({ code: '200', data: {} }); await createCustomsCase({ customerId: '8', orderId: '2', inboundId: '3', checklistVersionId: '4', expectedVersion: 0 }); await submitCustomsCase('1', { customsAgentName: '代理', expectedVersion: 2 }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }); expect(vi.mocked(http.post).mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 2 }) }) })
@@ -0,0 +1,11 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { CustomsCaseCreateRequest, CustomsCaseDetail, CustomsCasePage, CustomsCaseQuery, CustomsCaseResource } from '../types'
const basePath = '/platform/v1/customs'
const keys = ['id', 'tenantId', 'orderId', 'inboundId', 'containerBookingId', 'checklistVersionId'] as const
function item(raw: CustomsCaseResource) { const value = { ...raw } as CustomsCaseResource & Record<string, unknown>; for (const key of keys) if (value[key] != null) value[key] = String(value[key]); value.version = Number(value.version ?? 0); return value }
function detail(raw: CustomsCaseDetail): CustomsCaseDetail { return { ...raw, customsCase: item(raw.customsCase), documents: (raw.documents || []).map((x) => ({ ...x, id: String(x.id) })), submissionBatches: (raw.submissionBatches || []).map((x) => ({ ...x, id: String(x.id) })), receipts: (raw.receipts || []).map((x) => ({ ...x, id: String(x.id) })), history: (raw.history || []).map((x) => ({ ...x, id: String(x.id) })) } }
function idem() { return `customs-${Date.now()}-${Math.random().toString(36).slice(2)}` }
export async function listCustomsCases(query: CustomsCaseQuery) { const response = await http.get<ApiEnvelope<CustomsCasePage>, ApiEnvelope<CustomsCasePage>>(`${basePath}/cases`, { params: query }); const data = unwrapApiResponse(response); return { ...data, records: (data.records || []).map(item), page: Number(data.page), size: Number(data.size), total: Number(data.total), pages: Number(data.pages) } }
export async function getCustomsCase(id: string) { const response = await http.get<ApiEnvelope<CustomsCaseDetail>, ApiEnvelope<CustomsCaseDetail>>(`${basePath}/cases/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) }
export async function createCustomsCase(request: CustomsCaseCreateRequest) { const response = await http.post<ApiEnvelope<CustomsCaseDetail>, ApiEnvelope<CustomsCaseDetail>>(`${basePath}/cases`, request, { headers: { 'Idempotency-Key': idem() } }); return detail(unwrapApiResponse(response)) }
export async function submitCustomsCase(id: string, request: { customsAgentName: string; externalReference?: string; note?: string; expectedVersion: number }) { const response = await http.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(`${basePath}/cases/${encodeURIComponent(id)}/submission-batches`, request, { headers: { 'Idempotency-Key': idem() } }); return unwrapApiResponse(response) }
+2
View File
@@ -0,0 +1,2 @@
export { customsRoutes } from './routes'
export * from './types'
@@ -0,0 +1,5 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/customs/pages/CustomsCasesView.vue'), 'utf8')
describe('CustomsCasesView', () => { it('uses shared list and dialog components', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain('AppDialog'); expect(source).toContain("fixed: 'left'") }); it('supports create, detail, history and agent submission', () => { expect(source).toContain('createCustomsCase'); expect(source).toContain('getCustomsCase'); expect(source).toContain('submitCustomsCase'); expect(source).toContain('detail.history') }) })
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Eye, Plus, Send } from '@lucide/vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createCustomsCase, getCustomsCase, listCustomsCases, submitCustomsCase } from '../api/platformCustoms'
import type { CustomsCaseCreateRequest, CustomsCaseDetail, CustomsCaseResource } from '../types'
const access = useAccessPreviewStore(); const rows = ref<CustomsCaseResource[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
const filters = reactive<QueryModel>({ orderId: '', inboundId: '', status: '' }); const fields: QueryField[] = [{ key: 'orderId', type: 'input', label: '订单 ID', width: 170 }, { key: 'inboundId', type: 'input', label: '入库单 ID', width: 170 }, { key: 'status', type: 'select', label: '状态', options: ['DRAFT', 'COLLECTING', 'READY', 'SUBMITTED', 'RELEASED', 'REJECTED'].map((value) => ({ label: value, value })) }]
const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 175, slotName: 'actions', hideable: false, movable: false }, { prop: 'customsNo', label: '报关编号', minWidth: 145 }, { prop: 'orderId', label: '订单 ID', minWidth: 155 }, { prop: 'inboundId', label: '入库单 ID', minWidth: 155 }, { prop: 'checklistCode', label: '资料清单', minWidth: 125 }, { prop: 'checklistVersionNo', label: '版本', width: 90 }, { prop: 'status', label: '状态', width: 110, slotName: 'status' }, { prop: 'updatedAt', label: '更新时间', minWidth: 160 }]
async function load() { loading.value = true; error.value = null; try { const result = await listCustomsCases({ page: page.value, size: pageSize.value, orderId: String(filters.orderId || '') || undefined, inboundId: String(filters.inboundId || '') || undefined, status: String(filters.status || '') || undefined }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } }
function search() { if (page.value === 1) void load(); else page.value = 1 }
const createOpen = ref(false); const createSaving = ref(false); const createError = ref<ApiErrorDetails | null>(null); const formRef = ref<FormInstance>(); const createForm = reactive<CustomsCaseCreateRequest>({ customerId: '', orderId: '', inboundId: '', containerBookingId: null, checklistVersionId: '', remark: '', expectedVersion: 0 }); const rules: FormRules = { customerId: [{ required: true, message: '请输入客户 ID', trigger: 'blur' }], orderId: [{ required: true, message: '请输入订单 ID', trigger: 'blur' }], inboundId: [{ required: true, message: '请输入入库单 ID', trigger: 'blur' }], checklistVersionId: [{ required: true, message: '请输入资料清单版本 ID', trigger: 'blur' }] }
function openCreate() { Object.assign(createForm, { customerId: '', orderId: '', inboundId: '', containerBookingId: null, checklistVersionId: '', remark: '', expectedVersion: 0 }); createError.value = null; createOpen.value = true }
async function create() { if (!await formRef.value?.validate().catch(() => false)) return; createSaving.value = true; createError.value = null; try { await createCustomsCase({ ...createForm }); ElMessage.success('报关协同已创建'); createOpen.value = false; await load() } catch (cause) { createError.value = getApiErrorDetails(cause) } finally { createSaving.value = false } }
const detailOpen = ref(false); const detail = ref<CustomsCaseDetail | null>(null); const detailLoading = ref(false); const actionError = ref<ApiErrorDetails | null>(null); const submitForm = reactive({ customsAgentName: '', externalReference: '', note: '' }); const canSubmit = computed(() => detail.value && access.can('platform:firstleg:customs:submit') && !['SUBMITTED', 'RELEASED'].includes(detail.value.customsCase.status))
async function openDetail(id: string) { detailOpen.value = true; detailLoading.value = true; actionError.value = null; try { detail.value = await getCustomsCase(id) } catch (cause) { actionError.value = getApiErrorDetails(cause) } finally { detailLoading.value = false } }
async function submit() { if (!detail.value || !submitForm.customsAgentName.trim()) return; actionError.value = null; try { await submitCustomsCase(detail.value.customsCase.id, { ...submitForm, expectedVersion: detail.value.customsCase.version }); ElMessage.success('已登记线下提交批次'); detail.value = await getCustomsCase(detail.value.customsCase.id) } catch (cause) { actionError.value = getApiErrorDetails(cause); if (actionError.value.code === 'A0409') detail.value = await getCustomsCase(detail.value.customsCase.id) } }
watch([page, pageSize], () => void load()); onMounted(load)
</script>
<template><AdminPage as="div"><ListPageLayout aria-label="报关协同列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="报关协同筛选" :fields="fields" @search="search" /></template><template v-if="error" #state><LoadErrorState title="报关协同加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" preference-key="customs-cases" empty-text="暂无报关协同记录"><template #toolbar-actions><el-button v-if="access.can('platform:firstleg:customs:manage')" type="primary" @click="openCreate"><Plus :size="16" />新增报关</el-button></template><template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:customs:view')" link type="primary" @click="openDetail(row.id)"><Eye :size="15" />查看</el-button></template><template #status="{ row }"><StatusBadge :status="row.status" /></template></DataTable></section></ListPageLayout>
<AppDialog v-model="createOpen" title="新增报关协同" width="720px" :confirm-loading="createSaving" @confirm="create"><el-alert v-if="createError" type="error" :closable="false"><ApiErrorMeta :details="createError" /></el-alert><el-form ref="formRef" :model="createForm" :rules="rules" label-position="top"><el-row :gutter="16"><el-col :xs="24" :sm="12"><el-form-item label="客户 ID" prop="customerId"><el-input v-model="createForm.customerId" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="订单 ID" prop="orderId"><el-input v-model="createForm.orderId" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="入库单 ID" prop="inboundId"><el-input v-model="createForm.inboundId" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="柜次 ID"><el-input v-model="createForm.containerBookingId" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="资料清单版本 ID" prop="checklistVersionId"><el-input v-model="createForm.checklistVersionId" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" /></el-form-item></el-col></el-row></el-form></AppDialog>
<AppDialog v-model="detailOpen" title="报关协同详情" width="900px" :show-confirm="false"><LoadErrorState v-if="actionError && !detail" title="详情加载失败" :message="actionError.message" :loading="detailLoading" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState><template v-if="detail"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><el-descriptions :column="2" border><el-descriptions-item label="报关编号">{{ detail.customsCase.customsNo }}</el-descriptions-item><el-descriptions-item label="状态"><StatusBadge :status="detail.customsCase.status" /></el-descriptions-item><el-descriptions-item label="资料清单">{{ detail.customsCase.checklistCode }} / {{ detail.customsCase.checklistVersionNo }}</el-descriptions-item><el-descriptions-item label="资料/回执">{{ detail.documents.length }} / {{ detail.receipts.length }}</el-descriptions-item></el-descriptions><el-form v-if="canSubmit" label-position="top" class="submit-form"><el-row :gutter="12"><el-col :xs="24" :sm="12"><el-form-item label="报关代理"><el-input v-model="submitForm.customsAgentName" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="外部参考号"><el-input v-model="submitForm.externalReference" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="提交备注"><el-input v-model="submitForm.note" /></el-form-item></el-col></el-row><el-button type="primary" @click="submit"><Send :size="16" />登记线下提交</el-button></el-form><h3>状态历史</h3><el-timeline><el-timeline-item v-for="entry in detail.history" :key="entry.id" :timestamp="entry.createdAt">{{ entry.action }}:{{ entry.fromStatus || '—' }} → {{ entry.toStatus || '—' }}<small>{{ entry.note || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template>
<style scoped>.submit-form{margin-top:18px}.submit-form+.el-button{margin-bottom:12px}h3{margin:20px 0 12px}small{display:block;color:var(--cc-muted)}</style>
+2
View File
@@ -0,0 +1,2 @@
import type { RouteRecordRaw } from 'vue-router'
export const customsRoutes = [{ path: 'customs', name: 'customs-cases', component: () => import('./pages/CustomsCasesView.vue'), meta: { title: '报关协同', description: '维护报关资料、代理提交与放行回执', permission: 'platform:firstleg:customs:list' } }] satisfies RouteRecordRaw[]
+7
View File
@@ -0,0 +1,7 @@
export interface CustomsCaseResource { id: string; customsNo: string; tenantId?: string | null; orderId: string; inboundId: string; containerBookingId?: string | null; checklistVersionId: string; checklistCode: string; checklistVersionNo: string; status: string; remark?: string | null; version: number; updatedAt?: string | null }
export interface CustomsDocumentResource { id: string; itemCode: string; fileName?: string | null; status?: string | null; version?: number }
export interface CustomsHistoryResource { id: string; action: string; fromStatus?: string | null; toStatus?: string | null; note?: string | null; operatorName?: string | null; createdAt: string }
export interface CustomsCaseDetail { customsCase: CustomsCaseResource; documents: CustomsDocumentResource[]; supplements: unknown[]; submissionBatches: Array<{ id: string; customsAgentName?: string; status?: string; createdAt?: string }>; receipts: Array<{ id: string; receiptType?: string; status?: string; createdAt?: string }>; history: CustomsHistoryResource[] }
export interface CustomsCasePage { records: CustomsCaseResource[]; page: number; size: number; total: number; pages: number }
export interface CustomsCaseQuery { page: number; size: number; customerId?: string; orderId?: string; inboundId?: string; containerBookingId?: string; status?: string }
export interface CustomsCaseCreateRequest { customerId: string; orderId: string; inboundId: string; containerBookingId?: string | null; checklistVersionId: string; remark?: string | null; expectedVersion: number }
@@ -6,6 +6,7 @@ import { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails } from '@/shared/api/http'
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 AppDialog from '@/shared/components/AppDialog.vue'
@@ -199,9 +200,9 @@ onMounted(load)
</div>
</template>
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load" />
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else>
<el-alert v-if="actionError" type="error" :closable="false" show-icon>{{ actionError.message }}</el-alert>
<el-alert v-if="actionError" type="error" :closable="false" show-icon><ApiErrorMeta :details="actionError" /></el-alert>
<section class="surface-panel">
<div class="section-title">
<div><h2>订舱信息</h2><p v-if="detail && !editable">当前状态仅可查看,草稿状态才允许编辑。</p></div>
@@ -6,6 +6,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import DataTable from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import { getApiErrorDetails } from '@/shared/api/http'
@@ -47,7 +48,7 @@ watch([page, pageSize], () => void load()); onMounted(load)
<AdminPage as="div" class="ocean-bookings-page">
<ListPageLayout aria-label="订舱列表">
<template #toolbar><QueryToolbar v-model="query" class="surface-panel" ariaLabel="订舱筛选" :fields="fields" @search="search" /></template>
<template v-if="error" #state><LoadErrorState title="订舱列表加载失败" :message="error.message" :loading="loading" @retry="load" /></template>
<template v-if="error" #state><LoadErrorState title="订舱列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱">
<template #toolbar-actions><el-button v-if="access.can('platform:firstleg:ocean-booking:create')" type="primary" @click="open()"><Plus :size="16" />新增订舱</el-button></template>
@@ -0,0 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { changeOutboundDispatch, listOutboundDispatches } from './platformOutboundDispatch'
vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http'); return { ...actual, http: { get: vi.fn(), post: vi.fn() } } })
describe('platformOutboundDispatch', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes aggregate ids', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', customerId: 3, version: '4' }] }); const result = await listOutboundDispatches({}); expect(result[0]).toMatchObject({ orderId: '1', inboundId: '2', customerId: '3', version: 4 }) }); it('posts action with version and idempotency', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate: { orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', version: 5 }, timeline: [] } }); await changeOutboundDispatch('confirm-dispatch', { inboundId: '2', expectedVersion: 4 }); expect(vi.mocked(http.post).mock.calls[0]?.[1]).toEqual({ inboundId: '2', expectedVersion: 4 }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) }) })
@@ -0,0 +1,9 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OutboundDispatchDetail, OutboundDispatchQuery, OutboundDispatchResource, OutboundGateResult } from '../types'
const basePath = '/platform/v1/first-leg/outbound-dispatches'; const keys = ['orderId', 'inboundId', 'containerBookingId', 'customerId'] as const
function item(raw: OutboundDispatchResource) { const value = { ...raw } as OutboundDispatchResource & Record<string, unknown>; for (const key of keys) if (value[key] != null) value[key] = String(value[key]); value.version = Number(value.version ?? 0); return value }
function idem() { return `outbound-dispatch-${Date.now()}-${Math.random().toString(36).slice(2)}` }
export async function listOutboundDispatches(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundDispatchResource[]>, ApiEnvelope<OutboundDispatchResource[]>>(basePath, { params: query }); return (unwrapApiResponse(response) || []).map(item) }
export async function getOutboundDispatchDetail(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundDispatchDetail>, ApiEnvelope<OutboundDispatchDetail>>(`${basePath}/detail`, { params: query }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } }
export async function checkOutboundGate(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundGateResult>, ApiEnvelope<OutboundGateResult>>(`${basePath}/check-gate`, { params: query }); return unwrapApiResponse(response) }
export async function changeOutboundDispatch(action: 'confirm-dispatch' | 'reverse-dispatch', request: { inboundId: string; containerBookingId?: string | null; expectedVersion: number; remark?: string }) { const response = await http.post<ApiEnvelope<OutboundDispatchDetail>, ApiEnvelope<OutboundDispatchDetail>>(`${basePath}/${action}`, request, { headers: { 'Idempotency-Key': idem() } }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } }
+2
View File
@@ -0,0 +1,2 @@
export { outboundDispatchRoutes } from './routes'
export * from './types'
@@ -0,0 +1,5 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8')
describe('OutboundDispatchesView', () => { it('uses standard list components and left actions', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain("fixed: 'left'") }); it('shows gate, timeline and guarded dispatch actions', () => { expect(source).toContain('aggregate.gate?.items'); expect(source).toContain('detail.timeline'); expect(source).toContain("'confirm-dispatch'"); expect(source).toContain("'reverse-dispatch'"); expect(source).toContain("error.value?.code === 'A0409'") }) })
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Eye, RotateCcw, Send } from '@lucide/vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { changeOutboundDispatch, getOutboundDispatchDetail, listOutboundDispatches } from '../api/platformOutboundDispatch'
import type { OutboundDispatchDetail, OutboundDispatchResource } from '../types'
const access = useAccessPreviewStore(); const rows = ref<OutboundDispatchResource[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const filters = reactive<QueryModel>({ orderId: '', inboundId: '', containerBookingId: '' })
const fields: QueryField[] = [{ key: 'orderId', type: 'input', label: '订单 ID' }, { key: 'inboundId', type: 'input', label: '入库单 ID' }, { key: 'containerBookingId', type: 'input', label: '柜次 ID' }]
const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 240, slotName: 'actions', hideable: false, movable: false }, { prop: 'orderNo', label: '订单号', minWidth: 145 }, { prop: 'inboundCode', label: '入库单号', minWidth: 145 }, { prop: 'customerName', label: '客户', minWidth: 140 }, { prop: 'containerNo', label: '柜号', minWidth: 130 }, { prop: 'oceanBookingStatus', label: '订舱', width: 105, slotName: 'booking' }, { prop: 'truckAppointmentStatus', label: '拖车', width: 105, slotName: 'truck' }, { prop: 'pickTaskStatus', label: '拣货', width: 105, slotName: 'pick' }, { prop: 'dispatchStatus', label: '出仓状态', width: 115, slotName: 'status' }, { key: 'gate', label: '门禁', width: 90, slotName: 'gate' }]
function query() { return { orderId: String(filters.orderId || '') || undefined, inboundId: String(filters.inboundId || '') || undefined, containerBookingId: String(filters.containerBookingId || '') || undefined } }
async function load() { loading.value = true; error.value = null; try { rows.value = await listOutboundDispatches(query()) } catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } }
const detailOpen = ref(false); const detail = ref<OutboundDispatchDetail | null>(null); const actionError = ref<ApiErrorDetails | null>(null)
async function open(value: unknown) { const row = value as OutboundDispatchResource; detailOpen.value = true; actionError.value = null; try { detail.value = await getOutboundDispatchDetail({ inboundId: row.inboundId, containerBookingId: row.containerBookingId || undefined }) } catch (cause) { actionError.value = getApiErrorDetails(cause) } }
async function action(value: unknown, name: 'confirm-dispatch' | 'reverse-dispatch') { const row = value as OutboundDispatchResource; try { await ElMessageBox.confirm(name === 'confirm-dispatch' ? '确认国内出仓?系统将校验全部装柜门禁。' : '确认撤销国内出仓?', '出仓状态确认', { type: name === 'reverse-dispatch' ? 'warning' : 'info' }); await changeOutboundDispatch(name, { inboundId: row.inboundId, containerBookingId: row.containerBookingId, expectedVersion: row.version, remark: name === 'confirm-dispatch' ? '后台确认出仓' : '后台撤销出仓' }); ElMessage.success('出仓状态已更新'); await load() } catch (cause) { if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) error.value = getApiErrorDetails(cause); if (error.value?.code === 'A0409') await load() } }
onMounted(load)
</script>
<template><AdminPage as="div"><ListPageLayout aria-label="国内出仓列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="国内出仓筛选" :fields="fields" @search="load" /></template><template v-if="error" #state><LoadErrorState title="国内出仓加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable :data="rows" :columns="columns" :loading="loading" row-key="inboundId" preference-key="outbound-dispatches" empty-text="暂无符合条件的出仓记录"><template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:outbound-dispatch:view')" link type="primary" @click="open(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:confirm') && row.dispatchStatus !== 'DISPATCHED'" link type="primary" @click="action(row, 'confirm-dispatch')"><Send :size="15" />确认</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:reverse') && row.dispatchStatus === 'DISPATCHED'" link type="danger" @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></template><template #booking="{ row }"><StatusBadge :status="row.oceanBookingStatus || '未关联'" /></template><template #truck="{ row }"><StatusBadge :status="row.truckAppointmentStatus || '未关联'" /></template><template #pick="{ row }"><StatusBadge :status="row.pickTaskStatus || '未关联'" /></template><template #status="{ row }"><StatusBadge :status="row.dispatchStatus || '待处理'" /></template><template #gate="{ row }"><el-tag :type="row.gate?.passed ? 'success' : 'danger'">{{ row.gate?.passed ? '通过' : '未通过' }}</el-tag></template></DataTable></section></ListPageLayout><AppDialog v-model="detailOpen" title="国内出仓详情" width="900px" :show-confirm="false"><LoadErrorState v-if="actionError" title="出仓详情加载失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState><template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="订单号">{{ detail.aggregate.orderNo }}</el-descriptions-item><el-descriptions-item label="入库单号">{{ detail.aggregate.inboundCode }}</el-descriptions-item><el-descriptions-item label="柜号">{{ detail.aggregate.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="出仓状态"><StatusBadge :status="detail.aggregate.dispatchStatus || '待处理'" /></el-descriptions-item></el-descriptions><h3>统一门禁</h3><el-table :data="detail.aggregate.gate?.items || []"><el-table-column label="结果" width="80"><template #default="scope"><el-tag :type="scope.row.passed ? 'success' : 'danger'">{{ scope.row.passed ? '通过' : '阻断' }}</el-tag></template></el-table-column><el-table-column prop="name" label="门禁" min-width="140" /><el-table-column prop="actual" label="当前情况" min-width="180" /><el-table-column prop="requirement" label="要求" min-width="180" /></el-table><h3>整体时间线</h3><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="`${entry.module}-${entry.occurredAt}-${entry.action}`" :timestamp="entry.occurredAt">{{ entry.module }} · {{ entry.action }}:{{ entry.fromStatus || '—' }} → {{ entry.toStatus || '—' }}<small>{{ entry.operatorName || '系统' }} {{ entry.remark || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template>
<style scoped>h3{margin:20px 0 12px}small{display:block;color:var(--cc-muted)}</style>
+2
View File
@@ -0,0 +1,2 @@
import type { RouteRecordRaw } from 'vue-router'
export const outboundDispatchRoutes = [{ path: 'outbound-dispatches', name: 'outbound-dispatches', component: () => import('./pages/OutboundDispatchesView.vue'), meta: { title: '国内出仓', description: '查看装柜门禁、整体时间线并确认国内出仓', permission: 'platform:firstleg:outbound-dispatch:list' } }] satisfies RouteRecordRaw[]
+5
View File
@@ -0,0 +1,5 @@
export interface OutboundGateItem { code: string; name: string; passed: boolean; actual?: string | null; requirement?: string | null }
export interface OutboundGateResult { passed: boolean; items: OutboundGateItem[]; checkedAt: string }
export interface OutboundDispatchResource { orderId: string; orderNo: string; inboundId: string; inboundCode: string; customerId?: string | null; customerName?: string | null; inboundStatus?: string | null; containerBookingId?: string | null; containerNo?: string | null; loadingPlanStatus?: string | null; pickTaskStatus?: string | null; oceanBookingStatus?: string | null; truckAppointmentStatus?: string | null; dispatchStatus?: string | null; version: number; gate?: OutboundGateResult | null }
export interface OutboundDispatchDetail { aggregate: OutboundDispatchResource; timeline: Array<{ module: string; action: string; fromStatus?: string | null; toStatus?: string | null; operatorName?: string | null; remark?: string | null; occurredAt: string }> }
export interface OutboundDispatchQuery { orderId?: string; inboundId?: string; containerBookingId?: string }
@@ -0,0 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { createPortSchedule, listPortSchedules, updatePortSchedule } from './platformPortSchedules'
vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http'); return { ...actual, http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } } })
const data = { customerId: '1', oceanBookingId: '2', sourceType: 'MANUAL', sourceName: '美森', sourceRef: 'web', vesselName: 'MATSON', voyageNo: 'V1', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', observedAt: '2026-08-11T10:00:00', expiresAt: '2026-08-14T10:00:00' }
describe('platformPortSchedules', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes long ids in pages', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ schedule: { ...data, id: 9, scheduleNo: 'PS1', customerId: 1, oceanBookingId: 2, version: '3' }, expired: false }], page: 1, size: 20, total: 1, pages: 1 } }); const result = await listPortSchedules({ page: 1, size: 20 }); expect(result.records[0]?.schedule).toMatchObject({ id: '9', customerId: '1', oceanBookingId: '2', version: 3 }) }); it('uses idempotency and expectedVersion for writes', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: { portSchedule: { schedule: { ...data, id: 9, scheduleNo: 'PS1', version: 0 }, expired: false }, histories: [] } }); vi.mocked(http.put).mockResolvedValue({ code: '200', data: { portSchedule: { schedule: { ...data, id: 9, scheduleNo: 'PS1', version: 2 }, expired: false }, histories: [] } }); await createPortSchedule(data); await updatePortSchedule('9', { expectedVersion: 1, data }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }); expect(vi.mocked(http.put).mock.calls[0]?.[1]).toMatchObject({ expectedVersion: 1 }) }) })
@@ -0,0 +1,24 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { PortScheduleData, PortScheduleDetail, PortSchedulePage, PortScheduleQuery, PortScheduleResource, PortScheduleSaveRequest, PortScheduleView } from '../types'
const basePath = '/platform/v1/port-schedules'
const ids = ['id', 'customerId', 'oceanBookingId', 'containerBookingId'] as const
function resource(raw: PortScheduleResource): PortScheduleResource {
const value = { ...raw } as PortScheduleResource & Record<string, unknown>
for (const key of ids) if (value[key] != null) value[key] = String(value[key])
value.version = Number(value.version ?? 0)
return value
}
function view(raw: PortScheduleView): PortScheduleView { return { ...raw, schedule: resource(raw.schedule) } }
function detail(raw: PortScheduleDetail): PortScheduleDetail {
return { ...raw, portSchedule: view(raw.portSchedule), histories: (raw.histories || []).map((item) => ({ ...item, id: String(item.id) })) }
}
function idem() { return `port-schedule-${Date.now()}-${Math.random().toString(36).slice(2)}` }
export async function listPortSchedules(query: PortScheduleQuery) {
const response = await http.get<ApiEnvelope<PortSchedulePage>, ApiEnvelope<PortSchedulePage>>(basePath, { params: query })
const data = unwrapApiResponse(response)
return { ...data, records: (data.records || []).map(view), page: Number(data.page), size: Number(data.size), total: Number(data.total), pages: Number(data.pages) }
}
export async function getPortSchedule(id: string) { const response = await http.get<ApiEnvelope<PortScheduleDetail>, ApiEnvelope<PortScheduleDetail>>(`${basePath}/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) }
export async function createPortSchedule(data: PortScheduleData) { const response = await http.post<ApiEnvelope<PortScheduleDetail>, ApiEnvelope<PortScheduleDetail>>(basePath, { expectedVersion: 0, data }, { headers: { 'Idempotency-Key': idem() } }); return detail(unwrapApiResponse(response)) }
export async function updatePortSchedule(id: string, request: PortScheduleSaveRequest) { const response = await http.put<ApiEnvelope<PortScheduleDetail>, ApiEnvelope<PortScheduleDetail>>(`${basePath}/${encodeURIComponent(id)}`, request, { headers: { 'Idempotency-Key': idem() } }); return detail(unwrapApiResponse(response)) }
+2
View File
@@ -0,0 +1,2 @@
export { portScheduleRoutes } from './routes'
export * from './types'
@@ -0,0 +1,5 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/port-schedules/pages/PortSchedulesView.vue'), 'utf8')
describe('PortSchedulesView', () => { it('uses standard list components and a left action column', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain("fixed: 'left'"); expect(source).toContain('AppDialog') }); it('exposes expiry and conflict handling', () => { expect(source).toContain("query.expired === 'true'"); expect(source).toContain("actionError.value.code === 'A0409'"); expect(source).toContain('ApiErrorMeta') }) })
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Eye, Pencil, Plus } from '@lucide/vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.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 { createPortSchedule, getPortSchedule, listPortSchedules, updatePortSchedule } from '../api/platformPortSchedules'
import type { PortScheduleData, PortScheduleView } from '../types'
const access = useAccessPreviewStore()
const rows = ref<PortScheduleView[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
const page = ref(1); const pageSize = ref(20); const total = ref(0)
const query = reactive<QueryModel>({ keyword: '', expired: '' })
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '关键词', placeholder: '船期号/船名/航次/港口', width: 230 }, { key: 'expired', type: 'select', label: '有效性', options: [{ label: '有效', value: 'false' }, { label: '已过期', value: 'true' }] }]
const columns = [
{ key: 'actions', label: '操作', fixed: 'left', width: 165, slotName: 'actions', hideable: false, movable: false },
{ prop: 'schedule.scheduleNo', label: '船期编号', minWidth: 145 }, { prop: 'schedule.sourceName', label: '信息来源', minWidth: 130 },
{ prop: 'schedule.vesselName', label: '船名/航次', minWidth: 155, slotName: 'vessel' }, { prop: 'schedule.departurePortName', label: '起运港', minWidth: 120 },
{ prop: 'schedule.destinationPortName', label: '目的港', minWidth: 120 }, { prop: 'schedule.etdAt', label: '预计开船', minWidth: 160 },
{ prop: 'schedule.observedAt', label: '查询时间', minWidth: 160 }, { prop: 'expired', label: '有效性', width: 95, slotName: 'expired' },
]
function blank(): PortScheduleData { const now = new Date().toISOString().slice(0, 19); return { customerId: '', oceanBookingId: '', containerBookingId: null, sourceType: 'MANUAL', sourceName: '', sourceRef: '', vesselName: '', voyageNo: '', departurePortCode: '', departurePortName: '', destinationPortCode: '', destinationPortName: '', etdAt: null, etaAt: null, bookingCutoffAt: null, customsCutoffAt: null, portCutoffAt: null, observedAt: now, expiresAt: now, remark: '' } }
const dialogOpen = ref(false); const saving = ref(false); const editingId = ref(''); const expectedVersion = ref(0); const formRef = ref<FormInstance>(); const form = reactive<PortScheduleData>(blank()); const actionError = ref<ApiErrorDetails | null>(null)
const rules: FormRules = { customerId: [{ required: true, message: '请输入客户 ID', trigger: 'blur' }], oceanBookingId: [{ required: true, message: '请输入订舱 ID', trigger: 'blur' }], sourceName: [{ required: true, message: '请输入信息来源', trigger: 'blur' }], sourceRef: [{ required: true, message: '请输入来源链接或参考号', trigger: 'blur' }], vesselName: [{ required: true, message: '请输入船名', trigger: 'blur' }], voyageNo: [{ required: true, message: '请输入航次', trigger: 'blur' }], departurePortCode: [{ required: true, message: '请输入起运港代码', trigger: 'blur' }], destinationPortCode: [{ required: true, message: '请输入目的港代码', trigger: 'blur' }], observedAt: [{ required: true, message: '请选择查询时间', trigger: 'change' }], expiresAt: [{ required: true, message: '请选择过期时间', trigger: 'change' }] }
const canCreate = computed(() => access.can('platform:firstleg:port-schedule:create'))
async function load() { loading.value = true; error.value = null; try { const result = await listPortSchedules({ page: page.value, size: pageSize.value, keyword: String(query.keyword || '') || undefined, expired: query.expired === '' ? undefined : query.expired === 'true' }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } }
function search() { if (page.value === 1) void load(); else page.value = 1 }
function openCreate() { editingId.value = ''; expectedVersion.value = 0; Object.assign(form, blank()); actionError.value = null; dialogOpen.value = true }
async function openEdit(id: string) { actionError.value = null; try { const result = await getPortSchedule(id); editingId.value = id; expectedVersion.value = result.portSchedule.schedule.version; Object.assign(form, result.portSchedule.schedule); dialogOpen.value = true } catch (cause) { error.value = getApiErrorDetails(cause) } }
async function save() { if (!await formRef.value?.validate().catch(() => false)) return; saving.value = true; actionError.value = null; try { if (editingId.value) await updatePortSchedule(editingId.value, { expectedVersion: expectedVersion.value, data: { ...form }, reason: '后台维护船期' }); else await createPortSchedule({ ...form }); ElMessage.success(editingId.value ? '船期已更新' : '船期已新增'); dialogOpen.value = false; await load() } catch (cause) { actionError.value = getApiErrorDetails(cause); if (actionError.value.code === 'A0409' && editingId.value) await openEdit(editingId.value) } finally { saving.value = false } }
watch([page, pageSize], () => void load()); onMounted(load)
</script>
<template>
<AdminPage as="div"><ListPageLayout aria-label="船期列表">
<template #toolbar><QueryToolbar v-model="query" class="surface-panel" ariaLabel="船期筛选" :fields="fields" @search="search" @reset="search" /></template>
<template v-if="error" #state><LoadErrorState title="船期列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="schedule.id" preference-key="port-schedules" empty-text="暂无符合条件的船期">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增船期</el-button></template>
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:port-schedule:view')" link type="primary" @click="openEdit(row.schedule.id)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:port-schedule:edit')" link @click="openEdit(row.schedule.id)"><Pencil :size="15" />编辑</el-button></template>
<template #vessel="{ row }">{{ row.schedule.vesselName }} / {{ row.schedule.voyageNo }}</template><template #expired="{ row }"><el-tag :type="row.expired ? 'danger' : 'success'">{{ row.expired ? '已过期' : '有效' }}</el-tag></template>
</DataTable></section>
</ListPageLayout>
<AppDialog v-model="dialogOpen" :title="editingId ? '编辑船期' : '新增船期'" width="820px" :confirm-loading="saving" @confirm="save"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="schedule-form"><el-row :gutter="16">
<el-col :xs="24" :sm="12"><el-form-item label="客户 ID" prop="customerId"><el-input v-model="form.customerId" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="订舱 ID" prop="oceanBookingId"><el-input v-model="form.oceanBookingId" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="信息来源" prop="sourceName"><el-input v-model="form.sourceName" placeholder="如美森官网" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="来源参考" prop="sourceRef"><el-input v-model="form.sourceRef" placeholder="查询链接或参考号" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="船名" prop="vesselName"><el-input v-model="form.vesselName" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="航次" prop="voyageNo"><el-input v-model="form.voyageNo" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="起运港代码" prop="departurePortCode"><el-input v-model="form.departurePortCode" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="目的港代码" prop="destinationPortCode"><el-input v-model="form.destinationPortCode" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="预计开船"><el-date-picker v-model="form.etdAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="预计到港"><el-date-picker v-model="form.etaAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="查询时间" prop="observedAt"><el-date-picker v-model="form.observedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="过期时间" prop="expiresAt"><el-date-picker v-model="form.expiresAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" /></el-form-item></el-col>
</el-row></el-form></AppDialog></AdminPage>
</template>
<style scoped>.schedule-form{max-height:65vh;overflow:auto;padding-inline:2px}</style>
+2
View File
@@ -0,0 +1,2 @@
import type { RouteRecordRaw } from 'vue-router'
export const portScheduleRoutes = [{ path: 'port-schedules', name: 'port-schedules', component: () => import('./pages/PortSchedulesView.vue'), meta: { title: '船期信息', description: '维护船公司船期、查询时间与过期标识', permission: 'platform:firstleg:port-schedule:list' } }] satisfies RouteRecordRaw[]
+34
View File
@@ -0,0 +1,34 @@
export interface PortScheduleResource {
id: string
scheduleNo: string
customerId: string
oceanBookingId: string
containerBookingId?: string | null
sourceType: string
sourceName: string
sourceRef: string
vesselName: string
voyageNo: string
departurePortCode: string
departurePortName?: string | null
destinationPortCode: string
destinationPortName?: string | null
etdAt?: string | null
etaAt?: string | null
bookingCutoffAt?: string | null
customsCutoffAt?: string | null
portCutoffAt?: string | null
observedAt: string
expiresAt: string
remark?: string | null
version: number
updatedAt?: string | null
}
export interface PortScheduleView { schedule: PortScheduleResource; expired: boolean }
export interface PortScheduleHistory { id: string; action: string; fromVersion?: number | null; toVersion?: number | null; reason?: string | null; operatorName?: string | null; createdAt: string }
export interface PortScheduleDetail { portSchedule: PortScheduleView; histories: PortScheduleHistory[] }
export interface PortSchedulePage { records: PortScheduleView[]; page: number; size: number; total: number; pages: number }
export interface PortScheduleQuery { page: number; size: number; keyword?: string; expired?: boolean }
export type PortScheduleData = Omit<PortScheduleResource, 'id' | 'scheduleNo' | 'version' | 'updatedAt'>
export interface PortScheduleSaveRequest { expectedVersion: number; data: PortScheduleData; reason?: string | null }