优化优化
This commit is contained in:
@@ -2,4 +2,59 @@ 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') }) })
|
||||
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('lets QueryToolbar issue only one search when reset', () => {
|
||||
expect(source).toContain('@search="search"')
|
||||
expect(source).not.toContain('@reset="search"')
|
||||
})
|
||||
|
||||
it('keeps view mode read-only without a save entry', () => {
|
||||
expect(source).toContain("openSchedule(row.schedule.id, 'view')")
|
||||
expect(source).toContain("openSchedule(row.schedule.id, 'edit')")
|
||||
expect(source).toContain(':show-confirm="!dialogReadonly"')
|
||||
expect(source).toContain(':disabled="dialogReadonly"')
|
||||
expect(source).toContain('if (dialogReadonly.value ||')
|
||||
})
|
||||
|
||||
it('selects real customers and customer-scoped ocean bookings', () => {
|
||||
expect(source).toContain("from '@/modules/iam'")
|
||||
expect(source).toContain("from '@/modules/ocean-bookings'")
|
||||
expect(source).toContain('listPlatformTenants({ page: 1, size: 200 })')
|
||||
expect(source).toContain('listOceanBookings({ page: 1, size: 200, customerId })')
|
||||
expect(source).toContain("form.oceanBookingId = ''")
|
||||
expect(source).not.toContain('label="客户 ID"')
|
||||
expect(source).not.toContain('label="订舱 ID"')
|
||||
})
|
||||
|
||||
it('protects candidate requests and exposes loading, empty, error and retry states', () => {
|
||||
expect(source).toContain('customerRequestSerial')
|
||||
expect(source).toContain('bookingRequestSerial')
|
||||
expect(source).toContain(':loading="customersLoading"')
|
||||
expect(source).toContain(':loading="bookingsLoading"')
|
||||
expect(source).toContain('no-data-text="暂无可用客户"')
|
||||
expect(source).toContain('no-data-text="该客户暂无订舱记录"')
|
||||
expect(source).toContain('@click="loadCustomers"')
|
||||
expect(source).toContain('@click="loadBookings()"')
|
||||
})
|
||||
|
||||
it('gives business fields actionable placeholders', () => {
|
||||
expect(source).toContain('placeholder="请输入船名"')
|
||||
expect(source).toContain('placeholder="请输入航次"')
|
||||
expect(source).toContain('placeholder="例如 CNSHA"')
|
||||
expect(source).toContain('placeholder="请选择预计开船时间"')
|
||||
expect(source).toContain('placeholder="请输入船期补充说明(可选)"')
|
||||
})
|
||||
|
||||
it('exposes expiry and conflict handling', () => {
|
||||
expect(source).toContain("query.expired === 'true'")
|
||||
expect(source).toContain("actionError.value.code === 'A0409'")
|
||||
expect(source).toContain('ApiErrorMeta')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,8 @@ 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 { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
|
||||
import { createPortSchedule, getPortSchedule, listPortSchedules, updatePortSchedule } from '../api/platformPortSchedules'
|
||||
import type { PortScheduleData, PortScheduleView } from '../types'
|
||||
|
||||
@@ -27,34 +29,45 @@ const columns = [
|
||||
{ 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' }] }
|
||||
type DialogMode = 'create' | 'view' | 'edit'
|
||||
const dialogOpen = ref(false); const dialogMode = ref<DialogMode>('create'); 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: '请选择客户', trigger: 'change' }], oceanBookingId: [{ required: true, message: '请选择订舱记录', trigger: 'change' }], 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 customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false); const customersError = ref<ApiErrorDetails | null>(null); let customerRequestSerial = 0
|
||||
const bookings = ref<OceanBookingResource[]>([]); const bookingsLoading = ref(false); const bookingsError = ref<ApiErrorDetails | null>(null); let bookingRequestSerial = 0
|
||||
const canCreate = computed(() => access.can('platform:firstleg:port-schedule:create'))
|
||||
const dialogTitle = computed(() => dialogMode.value === 'create' ? '新增船期' : dialogMode.value === 'edit' ? '编辑船期' : '查看船期')
|
||||
const dialogReadonly = computed(() => dialogMode.value === 'view')
|
||||
function customerLabel(customer: PlatformTenantResource) { return `${customer.tenantName}(${customer.tenantCode})` }
|
||||
function bookingLabel(booking: OceanBookingResource) { return [booking.bookingNo || '待生成订舱号', [booking.vesselName, booking.voyageNo].filter(Boolean).join(' / '), [booking.departurePortCode, booking.destinationPortCode].filter(Boolean).join(' → ')].filter(Boolean).join(' · ') }
|
||||
async function loadCustomers() { const serial = ++customerRequestSerial; customersLoading.value = true; customersError.value = null; try { const result = await listPlatformTenants({ page: 1, size: 200 }); if (serial === customerRequestSerial) customers.value = result.records } catch (cause) { if (serial === customerRequestSerial) customersError.value = getApiErrorDetails(cause, '客户候选加载失败') } finally { if (serial === customerRequestSerial) customersLoading.value = false } }
|
||||
async function loadBookings(customerId = String(form.customerId || '')) { const serial = ++bookingRequestSerial; bookings.value = []; bookingsError.value = null; if (!customerId) { bookingsLoading.value = false; return } bookingsLoading.value = true; try { const result = await listOceanBookings({ page: 1, size: 200, customerId }); if (serial === bookingRequestSerial && customerId === String(form.customerId || '')) bookings.value = result.records } catch (cause) { if (serial === bookingRequestSerial && customerId === String(form.customerId || '')) bookingsError.value = getApiErrorDetails(cause, '订舱候选加载失败') } finally { if (serial === bookingRequestSerial) bookingsLoading.value = false } }
|
||||
function handleCustomerChange(value: string) { form.customerId = String(value || ''); form.oceanBookingId = ''; void formRef.value?.clearValidate('oceanBookingId'); void loadBookings(form.customerId) }
|
||||
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 } }
|
||||
function openCreate() { dialogMode.value = 'create'; editingId.value = ''; expectedVersion.value = 0; Object.assign(form, blank()); bookings.value = []; bookingsError.value = null; actionError.value = null; dialogOpen.value = true; if (!customers.value.length) void loadCustomers() }
|
||||
async function openSchedule(id: string, mode: Extract<DialogMode, 'view' | 'edit'>) { actionError.value = null; try { const result = await getPortSchedule(id); dialogMode.value = mode; editingId.value = id; expectedVersion.value = result.portSchedule.schedule.version; Object.assign(form, result.portSchedule.schedule); dialogOpen.value = true; if (!customers.value.length) void loadCustomers(); void loadBookings(String(form.customerId || '')) } catch (cause) { error.value = getApiErrorDetails(cause) } }
|
||||
async function save() { if (dialogReadonly.value || !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 openSchedule(editingId.value, 'edit') } 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 #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 #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 #actions="{ row }"><el-button v-if="access.can('platform:firstleg:port-schedule:view')" link type="primary" @click="openSchedule(row.schedule.id, 'view')"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:port-schedule:edit')" link @click="openSchedule(row.schedule.id, 'edit')"><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>
|
||||
<AppDialog v-model="dialogOpen" :title="dialogTitle" width="820px" :confirm-loading="saving" :show-confirm="!dialogReadonly" :cancel-text="dialogReadonly ? '关闭' : '取消'" @confirm="save"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><div v-if="customersError" class="selector-error"><span>{{ customersError.message }}</span><el-button link type="primary" @click="loadCustomers">重新加载客户</el-button></div><div v-if="bookingsError" class="selector-error"><span>{{ bookingsError.message }}</span><el-button link type="primary" @click="loadBookings()">重新加载订舱</el-button></div><el-form ref="formRef" :model="form" :rules="rules" :disabled="dialogReadonly" label-position="top" class="schedule-form"><el-row :gutter="16">
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="客户" prop="customerId"><el-select :model-value="form.customerId" filterable placeholder="请选择客户" :loading="customersLoading" no-data-text="暂无可用客户" style="width:100%" @update:model-value="handleCustomerChange(String($event || ''))"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customerLabel(customer)" :value="String(customer.id)" :disabled="customer.status !== 'ACTIVE' && String(customer.id) !== String(form.customerId)" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="订舱记录" prop="oceanBookingId"><el-select v-model="form.oceanBookingId" filterable placeholder="请先选择客户,再选择订舱记录" :loading="bookingsLoading" :disabled="!form.customerId" no-data-text="该客户暂无订舱记录" style="width:100%"><el-option v-for="booking in bookings" :key="booking.id" :label="bookingLabel(booking)" :value="booking.id" /></el-select></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-col :xs="24" :sm="12"><el-form-item label="船名" prop="vesselName"><el-input v-model="form.vesselName" placeholder="请输入船名" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="航次" prop="voyageNo"><el-input v-model="form.voyageNo" placeholder="请输入航次" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="起运港代码" prop="departurePortCode"><el-input v-model="form.departurePortCode" placeholder="例如 CNSHA" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="目的港代码" prop="destinationPortCode"><el-input v-model="form.destinationPortCode" placeholder="例如 USLAX" /></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" placeholder="请选择预计开船时间" 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" placeholder="请选择预计到港时间" 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" placeholder="请选择本次查询时间" 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" placeholder="请选择结果过期时间" 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" maxlength="500" show-word-limit placeholder="请输入船期补充说明(可选)" /></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>
|
||||
<style scoped>.schedule-form{max-height:65vh;overflow:auto;padding-inline:2px}.selector-error{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;padding:6px 10px;border-radius:var(--cc-radius-sm);background:var(--cc-danger-soft);color:var(--el-color-danger);font-size:12px;line-height:20px}.selector-error span{overflow-wrap:anywhere}</style>
|
||||
|
||||
Reference in New Issue
Block a user