Release/test 合并上生产 #2
@@ -34,6 +34,7 @@ import {
|
||||
type PlatformTenantStatus,
|
||||
} from '@/modules/iam'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import CopyButton from '@/shared/components/CopyButton.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
@@ -1018,6 +1019,7 @@ onMounted(() => { void loadCustomers() })
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="customers-page">
|
||||
<OperationErrorToast :details="passwordError || impersonationError || archiveError" />
|
||||
<ListPageLayout aria-label="客户管理">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户筛选" :fields="queryFields" preference-key="customers-query" @search="applyFilters" /></template>
|
||||
<template v-if="loadError" #state>
|
||||
@@ -1158,7 +1160,6 @@ onMounted(() => { void loadCustomers() })
|
||||
|
||||
<el-dialog v-model="passwordDialogOpen" title="重置客户密码" width="min(520px, calc(100vw - 28px))" class="customer-password-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
|
||||
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
|
||||
<el-alert v-if="passwordError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ passwordError.message }}</span><ApiErrorMeta :details="passwordError" /></div></template></el-alert>
|
||||
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top">
|
||||
<el-form-item label="新密码" prop="newPassword" required><div class="password-input-row"><el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" placeholder="请输入 8-20 位新密码" /><el-button @click="randomPassword">随机生成</el-button></div></el-form-item>
|
||||
<el-form-item label="确认新密码" prop="confirmPassword" required><el-input v-model="passwordForm.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="请再次输入新密码" /></el-form-item>
|
||||
@@ -1171,7 +1172,6 @@ onMounted(() => { void loadCustomers() })
|
||||
<el-dialog v-model="impersonationDialogOpen" title="跳转登录客户工作台" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!impersonationSubmitting" :show-close="!impersonationSubmitting">
|
||||
<div v-if="impersonationTenant" class="decision-summary"><div><strong>{{ impersonationTenant.tenantName }}</strong><span>将以企业管理员身份打开客户工作台</span></div><span>客户编号:{{ impersonationTenant.tenantCode }}</span></div>
|
||||
<el-alert type="warning" :closable="false" show-icon>本操作会生成一次性、短时有效的客户会话,并记录代登录原因;不会替换当前 Admin 会话。</el-alert>
|
||||
<el-alert v-if="impersonationError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ impersonationError.message }}</span><ApiErrorMeta :details="impersonationError" /></div></template></el-alert>
|
||||
<el-form ref="impersonationFormRef" :model="impersonationForm" :rules="impersonationRules" label-position="top"><el-form-item label="代登录原因" prop="reason" required><el-input v-model="impersonationForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写 5-500 字的业务原因" /></el-form-item></el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="impersonationSubmitting" @click="impersonationDialogOpen = false">取消</el-button><el-button type="primary" :loading="impersonationSubmitting" @click="submitImpersonation">确认跳转</el-button></div></template>
|
||||
</el-dialog>
|
||||
@@ -1179,7 +1179,6 @@ onMounted(() => { void loadCustomers() })
|
||||
<el-dialog v-model="archiveDialogOpen" title="删除客户" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!archiveSubmitting" :show-close="!archiveSubmitting">
|
||||
<el-alert type="warning" :closable="false" show-icon>删除是逻辑归档:历史业务数据会保留,客户账号会被停用。是否允许归档由服务端最终判断。</el-alert>
|
||||
<div v-if="archiveTenant" class="archive-confirm"><strong>{{ archiveTenant.tenantName }}</strong><span>请输入客户编号 {{ archiveTenant.tenantCode }} 以确认。</span><el-input v-model="archiveConfirmCode" :disabled="archiveSubmitting" :placeholder="archiveTenant.tenantCode" autocomplete="off" /></div>
|
||||
<el-alert v-if="archiveError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ archiveError.message }}</span><ApiErrorMeta :details="archiveError" /></div></template></el-alert>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="archiveSubmitting" @click="archiveDialogOpen = false">取消</el-button><el-button type="danger" :loading="archiveSubmitting" :disabled="!archiveTenant || archiveConfirmCode.trim() !== archiveTenant.tenantCode" @click="submitArchive">确认删除</el-button></div></template>
|
||||
</el-dialog>
|
||||
</AdminPage>
|
||||
|
||||
@@ -79,8 +79,9 @@ describe('finance five-page delivery contract', () => {
|
||||
const columns = page.slice(page.indexOf('const columns:'))
|
||||
expect(columns.indexOf("key: 'actions'")).toBeLessThan(columns.indexOf("key: 'direction'"))
|
||||
expect(page).toContain('<ApiErrorMeta :details="detailError"')
|
||||
expect(page).toContain('<ApiErrorMeta :details="reviewError"')
|
||||
expect(page).toContain('<ApiErrorMeta :details="inclusionError"')
|
||||
expect(page).toContain('<OperationErrorToast :details="reviewError || inclusionError || generationError" />')
|
||||
expect(page).not.toContain('<ApiErrorMeta :details="reviewError"')
|
||||
expect(page).not.toContain('<ApiErrorMeta :details="inclusionError"')
|
||||
expect(page).not.toContain('接口尚未提供')
|
||||
expect(page).not.toContain('disabled title=')
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -312,6 +313,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="finance-arap-page" spacing="normal">
|
||||
<OperationErrorToast :details="reviewError || inclusionError || generationError" />
|
||||
<el-result v-if="!canView" icon="warning" title="无权访问客户应收应付" sub-title="请联系管理员开通账单查看权限" />
|
||||
<ListPageLayout v-else-if="!detailOpen" aria-label="客户应收应付">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="search" /></template>
|
||||
@@ -339,15 +341,12 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
</section>
|
||||
</AdminPage>
|
||||
<AppDialog v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||
<el-alert v-if="reviewError" type="error" :closable="false"><ApiErrorMeta :details="reviewError" /></el-alert>
|
||||
<el-form label-position="top"><el-form-item label="审核结论"><el-segmented v-model="reviewForm.action" :options="[{ label: '通过', value: 'APPROVE' }, { label: '驳回', value: 'REJECT' }]" /></el-form-item><el-form-item :label="reviewForm.action === 'REJECT' ? '驳回原因' : '审核说明(可选)'"><el-input v-model="reviewForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="reviewForm.action === 'REJECT' ? '请填写驳回原因' : '请输入审核说明(可选)'" show-word-limit /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="inclusionOpen" title="参与结算设置" confirm-text="保存设置" :confirm-loading="inclusionSaving" @confirm="submitInclusion">
|
||||
<el-alert v-if="inclusionError" type="error" :closable="false"><ApiErrorMeta :details="inclusionError" /></el-alert>
|
||||
<el-form label-position="top"><el-form-item label="结算状态"><el-switch v-model="inclusionForm.included" active-text="参与结算" inactive-text="排除结算" /></el-form-item><el-form-item label="调整原因"><el-input v-model="inclusionForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请说明参与或排除结算的原因" show-word-limit /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="generationOpen" :title="generationForm.mode === 'BY_CUTOFF' ? '按截止日期生成对账单' : '按勾选生成对账单'" confirm-text="生成草稿" :confirm-loading="generationSaving" @confirm="submitGeneration">
|
||||
<el-alert v-if="generationError" type="error" :closable="false"><ApiErrorMeta :details="generationError" /></el-alert>
|
||||
<el-form label-position="top"><el-form-item label="客户"><el-select v-model="generationForm.customerId" filterable placeholder="请选择客户" style="width:100%"><el-option v-for="customer in activeCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item><el-form-item label="业务期间"><el-date-picker v-model="generationForm.period" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="截止日期" style="width:100%" /></el-form-item><el-form-item v-if="generationForm.mode === 'SELECTED'" label="已选费用"><span>{{ selectedRows.length }} 笔,合计 {{ money(selectedAmount) }} CNY</span></el-form-item><el-form-item label="备注"><el-input v-model="generationForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
|
||||
@@ -38,7 +38,9 @@ describe('Finance formal settlement desktop presentation', () => {
|
||||
expect(source.match(/width="min\(760px, 94vw\)"/g)).toHaveLength(2)
|
||||
expect(source).toContain('.partial-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr));')
|
||||
expect(source).toContain('.settlement-operation-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));')
|
||||
expect(source).toContain('<el-alert v-if="receiptValidationError"')
|
||||
expect(source).toContain('<el-alert v-if="receiptError"')
|
||||
expect(source).toContain('<OperationErrorToast :message="receiptValidationError" />')
|
||||
expect(source).toContain('<OperationErrorToast :details="receiptError" />')
|
||||
expect(source).not.toContain('<el-alert v-if="receiptValidationError"')
|
||||
expect(source).not.toContain('<el-alert v-if="receiptError"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { categoryLabels, listSuppliers, type Supplier } from '@/modules/supplier
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -480,6 +481,8 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="finance-settlement-page" spacing="normal">
|
||||
<OperationErrorToast :message="receiptValidationError" />
|
||||
<OperationErrorToast :details="receiptError" />
|
||||
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
|
||||
<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel" :aria-label="`${title}详情`">
|
||||
<template #header><header class="settlement-detail-header">
|
||||
@@ -631,9 +634,6 @@ onMounted(() => {
|
||||
|
||||
<AppDialog v-model="receiptOpen" title="登记收款" width="min(760px, 94vw)" :confirm-loading="receiptBusy" @confirm="submitReceipt">
|
||||
<div v-if="detail" class="partial-summary"><div><span>结算单号</span><strong>{{ detail.settlementNo }}</strong></div><div><span>客户公司</span><strong>{{ detailPartyName() }}</strong></div><div><span>应收金额</span><strong>{{ money(detail.amount) }}</strong></div><div><span>已收金额</span><strong class="is-success">{{ money(detail.paidAmount) }}</strong></div><div><span>当前余额</span><strong class="is-danger">{{ money(detail.outstandingAmount) }}</strong></div></div>
|
||||
<el-alert v-if="receiptValidationError" type="error" :title="receiptValidationError" :closable="false" show-icon class="operation-error" />
|
||||
<el-alert v-if="receiptError" type="error" :title="receiptError.message" :closable="false" show-icon class="operation-error" />
|
||||
<ApiErrorMeta v-if="receiptError" :details="receiptError" class="operation-error-meta" />
|
||||
<el-form class="settlement-operation-form" label-position="top"><el-form-item label="收款日期"><el-date-picker v-model="receiptForm.receivedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="本次收款金额(CNY)"><el-input-number v-model="receiptForm.amount" :min="0.01" :max="detail?.outstandingAmount || 0.01" :precision="2" /></el-form-item><el-form-item label="收款方式"><el-select v-model="receiptForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="收款凭证"><el-upload :key="receiptUploadKey" :auto-upload="false" :limit="1" accept="image/*" :on-change="selectReceiptFile"><el-button>选择图片</el-button></el-upload></el-form-item><el-form-item label="备注"><el-input v-model="receiptForm.remark" type="textarea" /></el-form-item></el-form>
|
||||
<div v-if="detail" class="remaining-preview"><span>登记后剩余余额</span><strong>{{ remainingAfter(receiptForm.amount) }}</strong></div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
@@ -150,7 +150,8 @@ describe('finance G3/G4 page operations', () => {
|
||||
}, ['9001'])
|
||||
})
|
||||
|
||||
it('keeps receipt errors in the modal and rejects non-image vouchers before upload', async () => {
|
||||
it('shows receipt errors as dismissible light messages and rejects non-image vouchers before upload', async () => {
|
||||
const messageSpy = vi.spyOn(ElMessage, 'error')
|
||||
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:list', 'platform:finance:receipt:create'])
|
||||
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
@@ -172,10 +173,12 @@ describe('finance G3/G4 page operations', () => {
|
||||
vm.selectReceiptFile({ raw: new File(['receipt'], 'receipt.png', { type: 'image/png' }) })
|
||||
vi.mocked(uploadFinanceVoucher).mockRejectedValueOnce(new ApiResponseError('FIN_400: 凭证校验失败', 'FIN_400', 400, undefined, 'request-1', 'trace-1'))
|
||||
await vm.submitReceipt()
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.receiptOpen).toBe(true)
|
||||
expect(vm.receiptError?.message).toContain('FIN_400')
|
||||
expect(wrapper.text()).toContain('FIN_400')
|
||||
expect(messageSpy).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('FIN_400'), showClose: true, duration: 4500 }))
|
||||
expect(wrapper.text()).not.toContain('FIN_400')
|
||||
expect(wrapper.find('[aria-label="结算单应收列表"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { firstLegBusinessTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import {
|
||||
ApiResponseError,
|
||||
getApiErrorDetails,
|
||||
@@ -365,6 +366,8 @@ defineExpose({ reload: loadFee })
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<OperationErrorToast :details="adjustmentError" />
|
||||
|
||||
<AppDialog
|
||||
v-model="adjustOpen"
|
||||
title="调整入库费用"
|
||||
@@ -379,11 +382,6 @@ defineExpose({ reload: loadFee })
|
||||
<template #title>费用已被其他操作员更新,系统已刷新最新快照</template>
|
||||
<p>当前输入仍保留,请先核对最新版本后再提交。</p>
|
||||
</el-alert>
|
||||
<el-alert v-if="adjustmentError" class="adjustment-alert" type="error" :closable="false" show-icon role="alert">
|
||||
<template #title>费用调整失败</template>
|
||||
<p>{{ adjustmentError.message }}</p>
|
||||
<ApiErrorMeta :details="adjustmentError" />
|
||||
</el-alert>
|
||||
<el-form ref="adjustFormRef" :model="adjustmentForm" :rules="adjustmentRules" label-position="top" class="adjustment-form" :disabled="adjusting">
|
||||
<div class="adjustment-amounts">
|
||||
<el-form-item label="调整后计费量" prop="chargeableQuantity" required>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -275,6 +275,7 @@ describe('InboundMeasurementPanel', () => {
|
||||
})
|
||||
|
||||
it('shows a normal A0409 business reason instead of relabeling it as a version conflict', async () => {
|
||||
const messageSpy = vi.spyOn(ElMessage, 'error')
|
||||
vi.mocked(updateConfirmedPackageCount).mockRejectedValueOnce(
|
||||
new ApiResponseError('该托盘已被占用,请重新选择', 'A0409', 409),
|
||||
)
|
||||
@@ -285,11 +286,31 @@ describe('InboundMeasurementPanel', () => {
|
||||
await save?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('该托盘已被占用,请重新选择')
|
||||
expect(wrapper.text()).toContain('操作未完成')
|
||||
expect(messageSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: '该托盘已被占用,请重新选择',
|
||||
showClose: true,
|
||||
duration: 4500,
|
||||
}))
|
||||
expect(wrapper.text()).not.toContain('操作未完成')
|
||||
expect(wrapper.text()).not.toContain('数据版本已变化,当前修改尚未丢失')
|
||||
})
|
||||
|
||||
it('reports draft validation as a dismissible light message without a fixed page alert', async () => {
|
||||
const messageSpy = vi.spyOn(ElMessage, 'warning')
|
||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ items: [] }))
|
||||
const wrapper = await mountPanel()
|
||||
const save = wrapper.findAll('button').find((button) => button.text().includes('保存测量数据'))
|
||||
await save?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(messageSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: '请至少保留一条实际扫描箱测量记录',
|
||||
showClose: true,
|
||||
duration: 4500,
|
||||
}))
|
||||
expect(wrapper.text()).not.toContain('请修正测量草稿中的问题')
|
||||
})
|
||||
|
||||
it('requires the dirty draft to be saved before submitting for review', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
const countInput = wrapper.find('.measurement-count-editor input')
|
||||
|
||||
@@ -200,8 +200,7 @@ async function submitManualEntry() {
|
||||
await loadMeasurement()
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
const details = setOperationError(error, '手动箱规录入失败,请检查后重试')
|
||||
ElMessage.error(details.message)
|
||||
setOperationError(error, '手动箱规录入失败,请检查后重试')
|
||||
} finally {
|
||||
manualEntrySubmitting.value = false
|
||||
}
|
||||
@@ -311,14 +310,26 @@ function validateDraft() {
|
||||
})
|
||||
if (general.length) errors.general = general
|
||||
validationErrors.value = errors
|
||||
const messages = Object.values(errors).flat()
|
||||
if (messages.length) {
|
||||
ElMessage.warning({
|
||||
message: messages.length === 1 ? messages[0] : `${messages[0]}(另有 ${messages.length - 1} 项)`,
|
||||
showClose: true,
|
||||
duration: 4500,
|
||||
})
|
||||
}
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
|
||||
function setOperationError(error: unknown, fallback: string) {
|
||||
const details = getApiErrorDetails(error, fallback)
|
||||
operationError.value = details
|
||||
conflictDetected.value = details.message.includes('版本已变化')
|
||||
const conflict = details.message.includes('版本已变化')
|
||||
|| details.message.includes('并发修改')
|
||||
conflictDetected.value = conflict
|
||||
operationError.value = conflict ? details : null
|
||||
if (!conflict) {
|
||||
ElMessage.error({ message: details.message, showClose: true, duration: 4500 })
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
@@ -365,8 +376,7 @@ async function saveDraft() {
|
||||
emit('saved')
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
const details = setOperationError(error, '测量草稿保存失败,请检查后重试')
|
||||
if (!conflictDetected.value) ElMessage.error(details.message)
|
||||
setOperationError(error, '测量草稿保存失败,请检查后重试')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -400,8 +410,7 @@ async function submitForReview() {
|
||||
emit('submitted')
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
const details = setOperationError(error, '测量数据提交失败,请检查图片和称重结果后重试')
|
||||
if (!conflictDetected.value) ElMessage.error(details.message)
|
||||
setOperationError(error, '测量数据提交失败,请检查图片和称重结果后重试')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -505,18 +514,9 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
|
||||
<ApiErrorMeta v-if="operationError" :details="operationError" />
|
||||
<el-button size="small" @click="reloadLatest">加载最新版本</el-button>
|
||||
</el-alert>
|
||||
<el-alert v-else-if="operationError" type="error" :closable="false" show-icon class="measurement-alert">
|
||||
<template #title>操作未完成</template>
|
||||
<p>{{ operationError.message }}</p>
|
||||
<ApiErrorMeta :details="operationError" />
|
||||
</el-alert>
|
||||
|
||||
<el-alert v-if="Object.keys(validationErrors).length" type="error" :closable="false" show-icon class="measurement-alert">
|
||||
<template #title>请修正测量草稿中的问题</template>
|
||||
<ul>
|
||||
<li v-for="message in Object.values(validationErrors).flat()" :key="message">{{ message }}</li>
|
||||
</ul>
|
||||
</el-alert>
|
||||
<p v-if="Object.keys(validationErrors).length" class="sr-only" role="alert">
|
||||
{{ Object.values(validationErrors).flat().join(';') }}
|
||||
</p>
|
||||
|
||||
<form class="measurement-form" @submit.prevent="saveDraft">
|
||||
<fieldset :disabled="!mayMaintain || saving">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
||||
@@ -135,6 +136,7 @@ onMounted(() => void loadDetail())
|
||||
<template>
|
||||
<AdminPage>
|
||||
<div class="confirmed-loading-detail-page">
|
||||
<OperationErrorToast :details="customsError" />
|
||||
<el-button link type="primary" class="back-button" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<LoadErrorState v-if="error && !detail" title="装柜清单详情加载失败" :message="error.message" :loading="loading" @retry="loadDetail">
|
||||
<template #details><ApiErrorMeta :details="error" /></template>
|
||||
@@ -171,9 +173,6 @@ onMounted(() => void loadDetail())
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="customsError" compact title="操作失败" :message="customsError.message" :show-retry="false">
|
||||
<template #details><ApiErrorMeta :details="customsError" /></template>
|
||||
</LoadErrorState>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -47,7 +47,8 @@ describe('PreloadPlanDetailView prototype contract', () => {
|
||||
|
||||
it('keeps backend confirmation errors visible and supports narrow dialogs', () => {
|
||||
expect(source).toContain("confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')")
|
||||
expect(source).toContain(':message="confirmError.message"')
|
||||
expect(source).toContain('<OperationErrorToast :details="editError || confirmError" />')
|
||||
expect(source).not.toContain(':message="confirmError.message"')
|
||||
expect(source).toContain('width="min(520px, calc(100vw - 28px))"')
|
||||
expect(source).toContain("label=\"柜次号\"")
|
||||
expect(source).toContain("label=\"集装箱号\"")
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ArrowLeft } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -132,6 +133,7 @@ onMounted(() => void loadDetail())
|
||||
<template>
|
||||
<AdminPage>
|
||||
<div class="preload-detail-page">
|
||||
<OperationErrorToast :details="editError || confirmError" />
|
||||
<el-button link type="primary" class="back-button" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<el-alert
|
||||
v-if="detail"
|
||||
@@ -153,7 +155,6 @@ onMounted(() => void loadDetail())
|
||||
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
||||
<el-form-item label="修改原因" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" placeholder="选填,填写后将记录在操作历史中" /></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="editError" compact title="保存失败" :message="editError.message" :show-retry="false"><template #details><ApiErrorMeta :details="editError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload">
|
||||
@@ -164,7 +165,6 @@ onMounted(() => void loadDetail())
|
||||
<el-descriptions-item label="柜次号">{{ detail.summary.cabinetNo || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="集装箱号">{{ detail.summary.containerNo || '待录入' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<LoadErrorState v-if="confirmError" compact title="确认预装失败" :message="confirmError.message" :show-retry="false"><template #details><ApiErrorMeta :details="confirmError" /></template></LoadErrorState>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { PlatformTenantSelector } from '@/modules/iam'
|
||||
import { createNotification } from '../api/notificationCenter'
|
||||
import type { NotificationTemplate, RecipientType } from '../types'
|
||||
@@ -50,7 +50,7 @@ async function submit() {
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" title="新建消息" width="min(680px, calc(100vw - 28px))" :show-confirm="false" destroy-on-close @cancel="reset">
|
||||
<el-alert v-if="error" type="error" :closable="false" show-icon><template #title><span>{{ error.message }}</span><ApiErrorMeta :details="error" /></template></el-alert>
|
||||
<OperationErrorToast :details="error" />
|
||||
<el-form class="message-form" label-position="top">
|
||||
<div class="form-grid"><el-form-item label="发送范围" required><el-segmented v-model="scope" :options="[{ label: '平台员工', value: 'platform' }, { label: '指定企业', value: 'customer' }]" /></el-form-item><el-form-item label="发送方式"><el-input model-value="站内信" disabled /></el-form-item></div>
|
||||
<PlatformTenantSelector v-if="scope === 'customer'" v-model="form.tenantId" label="目标企业" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { createTemplate, updateTemplate } from '../api/notificationCenter'
|
||||
import type { NotificationTemplate } from '../types'
|
||||
@@ -35,8 +35,8 @@ async function submit() {
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" :title="editing ? '编辑消息模板' : '新增消息模板'" width="min(760px, calc(100vw - 28px))" :show-confirm="false" destroy-on-close>
|
||||
<el-alert v-if="error" type="error" :closable="false" show-icon><template #title><span>{{ error.message }}</span><ApiErrorMeta :details="error" /></template></el-alert>
|
||||
<el-alert v-else title="首期仅支持站内信;新模板保存为草稿,草稿激活后不可再编辑。" type="info" :closable="false" show-icon />
|
||||
<OperationErrorToast :details="error" />
|
||||
<el-alert title="首期仅支持站内信;新模板保存为草稿,草稿激活后不可再编辑。" type="info" :closable="false" show-icon />
|
||||
<el-form class="template-form" label-position="top"><div class="form-grid"><el-form-item label="模板CODE" required><el-input :model-value="form.code" maxlength="64" :disabled="editing" placeholder="如:ORDER_SHIPPED_NOTICE" @update:model-value="normalizeCode(String($event))" /></el-form-item><el-form-item label="模板名称" required><el-input v-model="form.name" maxlength="128" /></el-form-item></div><div class="form-grid"><el-form-item label="业务分类" required><el-input v-model="form.category" maxlength="64" /></el-form-item><el-form-item label="模板类型"><el-input model-value="站内信" disabled /></el-form-item></div><el-form-item label="消息标题模板" required><el-input v-model="form.title" maxlength="256" show-word-limit /></el-form-item><el-form-item label="消息正文模板" required><el-input ref="contentInput" v-model="form.content" type="textarea" :rows="6" maxlength="10000" show-word-limit /></el-form-item><el-form-item label="变量白名单"><el-input v-model="whitelistText" placeholder="多个变量使用逗号分隔,如 customer_name, order_no" /></el-form-item><div class="variable-row"><span>常用变量</span><el-button v-for="name in ['customer_name','order_no','container_no','amount','date']" :key="name" size="small" @click="insertVariable(name)">{{ name }}</el-button></div><section class="template-preview"><strong>内容预览</strong><p>{{ preview }}</p></section></el-form>
|
||||
<template #footer="{ close }"><div class="dialog-actions"><el-button :disabled="submitting" @click="close">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">保存模板</el-button></div></template>
|
||||
</AppDialog>
|
||||
|
||||
@@ -181,7 +181,8 @@ describe('OceanBookingDetailView interaction contract', () => {
|
||||
})
|
||||
|
||||
it('shows client-side validation failures instead of rendering an empty error alert', () => {
|
||||
expect(source).toContain(':title="actionError.message"')
|
||||
expect(source).toContain('<OperationErrorToast :details="actionError" />')
|
||||
expect(source).not.toContain(':title="actionError.message"')
|
||||
expect(source).toContain("actionError.value = { message: '请完善装柜仓库、物流渠道、真实运输资料和运输条款等必填信息' }")
|
||||
expect(source).toContain("actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }")
|
||||
expect(source).toContain('aria-label="真实柜登记"')
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -343,6 +344,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="booking-detail">
|
||||
<OperationErrorToast :details="actionError" />
|
||||
<DetailPageLayout aria-label="订舱详情">
|
||||
<template #header>
|
||||
<div class="detail-heading">
|
||||
@@ -355,7 +357,6 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
|
||||
<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 :title="actionError.message"><ApiErrorMeta :details="actionError" /></el-alert>
|
||||
<section class="surface-panel">
|
||||
<div class="section-title">
|
||||
<div><h2>订舱信息</h2><p v-if="detail && !editable">已确认订舱资料仅可查看,船期变更请使用“申请改船”。</p></div>
|
||||
@@ -453,7 +454,6 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<el-form-item label="预计到港"><el-date-picker v-model="changeForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="变更原因" required><el-input v-model="changeForm.reason" type="textarea" placeholder="请说明改船原因" /></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="actionError" compact title="提交失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
|
||||
<el-form label-position="top">
|
||||
@@ -467,7 +467,6 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</el-descriptions>
|
||||
<el-form-item label="备注"><el-input v-model="releaseReason" type="textarea" maxlength="1000" placeholder="请输入放舱备注(可选)" /></el-form-item>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="actionError" compact title="放舱失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ArrowLeft, FileText, Save } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -77,6 +78,7 @@ watch(supplierId, () => { void load() }, { immediate: true })
|
||||
|
||||
<template>
|
||||
<AdminPage width="comfortable">
|
||||
<OperationErrorToast :details="submitError" />
|
||||
<DetailPageLayout :aria-label="editing ? '编辑供应商' : '新增供应商'">
|
||||
<template #header><div class="form-page-header"><h2>{{ editing ? '编辑供应商' : '新增供应商' }}</h2><el-button @click="router.push({ name: 'supplier-list' })"><ArrowLeft :size="16" />返回</el-button></div></template>
|
||||
<LoadErrorState v-if="!access.can(requiredPermission)" title="暂无操作权限" :message="`请联系管理员开通${editing ? '供应商编辑' : '供应商新增'}权限。`" :show-retry="false" />
|
||||
@@ -105,7 +107,6 @@ watch(supplierId, () => { void load() }, { immediate: true })
|
||||
</FormSection>
|
||||
<FormSection title="其他信息"><el-form-item label="备注" prop="remark"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></FormSection>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="submitError" compact title="保存失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState>
|
||||
<footer class="form-actions"><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="router.push({ name: 'supplier-list' })">取消</el-button></footer>
|
||||
</section>
|
||||
</DetailPageLayout>
|
||||
|
||||
@@ -18,7 +18,8 @@ describe('truck appointment API', () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: '9001' })
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'DISPATCHED' } })
|
||||
await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' })
|
||||
await expect(createTruckAppointment({ plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' })
|
||||
await expect(createTruckAppointment({ customerId: '100', carrierId: '200', oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' })
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/truck-appointments', { customerId: '100', carrierId: '200', oceanBookingId: null, containerBookingId: null, loadingPlanId: null, plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })
|
||||
await expect(performTruckAppointmentAction('9001', 'DISPATCH', 2)).resolves.toMatchObject({ status: 'DISPATCHED' })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/dispatch', { expectedVersion: 2, reason: null }, { headers: { 'Idempotency-Key': expect.stringContaining('truck-appointment-dispatch-9001-') } })
|
||||
})
|
||||
|
||||
@@ -27,16 +27,36 @@ describe('TruckAppointmentForm interaction contract', () => {
|
||||
expect(source).toContain('selectCustomer')
|
||||
expect(source).toContain('draft.customerName')
|
||||
expect(source).toContain('selectContainer')
|
||||
expect(source).toContain('draft.loadingPlanId = item.planId')
|
||||
expect(source).toContain('draft.loadingPlanId = item.planId || null')
|
||||
expect(source).toContain('draft.loadingPlanId = null; draft.warehouseId = null')
|
||||
expect(source).toContain("derivedCustomerSource.value === 'container'")
|
||||
expect(source).toContain("derivedCustomerSource.value === 'booking'")
|
||||
expect(source).toContain('重新加载选项')
|
||||
})
|
||||
|
||||
it('hides inactive options while preserving resources already bound to an appointment', () => {
|
||||
it('keeps historical snapshots visible but requires active customer and carrier choices', () => {
|
||||
expect(source).toContain("item.status === 'ACTIVE'")
|
||||
expect(source).toContain("item.status === 'ACTIVE' || String(item.id) === String(draft.warehouseId)")
|
||||
expect(source).toContain('v-for="item in selectableCustomers"')
|
||||
expect(source).toContain('v-for="item in selectableWarehouses"')
|
||||
expect(source).not.toContain(':disabled="item.status !== \'ACTIVE\'')
|
||||
expect(source).toContain("customerId: [{ required: true, message: '请选择客户'")
|
||||
expect(source).toContain("carrierId: [{ required: true, message: '请选择承运商'")
|
||||
expect(source).toContain("plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间'")
|
||||
expect(source).toContain("draft.customerName || '历史客户(需重新选择)'")
|
||||
expect(source).toContain(':value="String(draft.customerId)" disabled')
|
||||
expect(source).toContain('props.resource?.oceanBookingNo')
|
||||
expect(source).toContain('props.resource?.cabinetNo')
|
||||
expect(source).not.toContain('props.resource?.containerBookingId].filter')
|
||||
})
|
||||
|
||||
it('keeps booking, cabinet and loading plan optional and only uses them for completion hints', () => {
|
||||
expect(source).toContain('订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约')
|
||||
expect(source).toContain('<el-divider content-position="left">可选关联</el-divider>')
|
||||
expect(source).toContain('label="关联柜次(可选)"')
|
||||
expect(source).toContain('label="关联订舱(可选)"')
|
||||
expect(source).toContain("relationWarning.value = '所选关联资源与当前客户不一致")
|
||||
expect(source).not.toContain("status === 'RELEASED'")
|
||||
expect(source).not.toContain('尚未建立装柜计划')
|
||||
})
|
||||
|
||||
it('gives editable fields clear placeholders', () => {
|
||||
@@ -50,4 +70,9 @@ describe('TruckAppointmentForm interaction contract', () => {
|
||||
expect(source).toContain('柜次号:${item.cabinetNo')
|
||||
expect(source).toContain('集装箱号:${item.containerNo')
|
||||
})
|
||||
|
||||
it('keeps the form usable at 390px', () => {
|
||||
expect(source).toContain('@media (max-width: 480px)')
|
||||
expect(source).toContain('min-height: 40px')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,8 @@ import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from
|
||||
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
|
||||
const formRef = ref<FormInstance>()
|
||||
const relationWarning = ref('')
|
||||
const derivedCustomerSource = ref<'booking' | 'container' | null>(null)
|
||||
const defaults: Partial<SaveTruckAppointmentRequest> = { expectedVersion: 0, currency: 'RMB', payableAmount: '0.00', receivableAmount: '0.00', packageCount: 0, palletCount: 0, transitMinutes: 0 }
|
||||
function normalizeDraft(value: SaveTruckAppointmentRequest): SaveTruckAppointmentRequest { return { ...defaults, ...value, currency: value.currency?.trim().toUpperCase() || 'RMB' } }
|
||||
const draft = reactive<SaveTruckAppointmentRequest>(normalizeDraft(props.modelValue))
|
||||
@@ -27,6 +29,7 @@ const phoneRule = (label: string) => ({ validator: (_rule: unknown, value: unkno
|
||||
else callback(new Error(`${label}格式不正确`))
|
||||
}, trigger: 'blur' })
|
||||
const rules: FormRules = {
|
||||
customerId: [{ required: true, message: '请选择客户', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCustomers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的启用客户')), trigger: 'change' }],
|
||||
carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }, { validator: (_rule, value, callback) => callback(selectableCarriers.value.some((item) => String(item.id) === String(value)) ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
|
||||
plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
|
||||
fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')],
|
||||
@@ -34,7 +37,7 @@ const rules: FormRules = {
|
||||
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], receivableAmount: [nonNegative('应收拖车费')],
|
||||
currency: [{ pattern: /^[A-Z]{3,8}$/, message: '币种请输入 3-8 位大写字母(如 RMB)', trigger: 'blur' }],
|
||||
}
|
||||
const relationText = computed(() => [props.resource?.customerName, props.resource?.outboundOrderId, props.resource?.containerBookingId].filter(Boolean).join(' / ') || '由服务端关联资源返回')
|
||||
const relationText = computed(() => [props.resource?.customerName, props.resource?.oceanBookingNo, props.resource?.cabinetNo, props.resource?.containerNo, props.resource?.warehouseName].filter(Boolean).join(' / ') || '无历史关联')
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const carriers = ref<Supplier[]>([])
|
||||
const containers = ref<ContainerBookingResource[]>([])
|
||||
@@ -42,9 +45,7 @@ const bookings = ref<OceanBookingResource[]>([])
|
||||
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
|
||||
const optionsLoading = ref(false)
|
||||
const optionsError = ref('')
|
||||
const selectableCustomers = computed(() => customers.value.filter((item) => (
|
||||
item.status === 'ACTIVE' || String(item.id) === String(draft.customerId)
|
||||
)))
|
||||
const selectableCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||||
const selectableWarehouses = computed(() => warehouses.value.filter((item) => (
|
||||
item.status === 'ACTIVE' || String(item.id) === String(draft.warehouseId)
|
||||
)))
|
||||
@@ -52,10 +53,35 @@ const selectableCarriers = computed(() => carriers.value.filter((item) => (
|
||||
item.status === 'ACTIVE'
|
||||
)))
|
||||
function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }
|
||||
function selectCustomer(customerId: string) { const customer = customers.value.find((item) => String(item.id) === customerId); draft.customerId = customerId || null; draft.customerName = customer ? customerLabel(customer) : null }
|
||||
function selectCustomer(customerId: string) { const customer = selectableCustomers.value.find((item) => String(item.id) === customerId); draft.customerId = customer?.id ? String(customer.id) : null; draft.customerName = customer ? customerLabel(customer) : null; derivedCustomerSource.value = null; relationWarning.value = '' }
|
||||
function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null }
|
||||
function selectContainer(containerId: string) { const item = containers.value.find((entry) => entry.id === containerId); draft.containerBookingId = containerId || null; if (item) { draft.loadingPlanId = item.planId; draft.warehouseId = item.warehouseId } }
|
||||
function selectBooking(bookingId: string) { const item = bookings.value.find((entry) => entry.id === bookingId); draft.oceanBookingId = bookingId || null; if (item?.customerId) selectCustomer(item.customerId) }
|
||||
function applyRelatedCustomer(customerId: string | null | undefined, customerName: string | null | undefined, source: 'booking' | 'container') {
|
||||
if (!customerId) return
|
||||
if (!draft.customerId) { draft.customerId = String(customerId); draft.customerName = customerName || null; derivedCustomerSource.value = source; return }
|
||||
if (String(draft.customerId) !== String(customerId)) relationWarning.value = '所选关联资源与当前客户不一致,请重新选择客户或清空该关联'
|
||||
}
|
||||
function selectContainer(containerId: string) {
|
||||
const item = containers.value.find((entry) => entry.id === containerId)
|
||||
draft.containerBookingId = item?.id || null
|
||||
relationWarning.value = ''
|
||||
if (!item) {
|
||||
draft.loadingPlanId = null; draft.warehouseId = null
|
||||
if (derivedCustomerSource.value === 'container') { draft.customerId = null; draft.customerName = null; derivedCustomerSource.value = null }
|
||||
return
|
||||
}
|
||||
draft.loadingPlanId = item.planId || null; draft.warehouseId = item.warehouseId || null
|
||||
applyRelatedCustomer(item.customerId, item.customerName, 'container')
|
||||
}
|
||||
function selectBooking(bookingId: string) {
|
||||
const item = bookings.value.find((entry) => entry.id === bookingId)
|
||||
draft.oceanBookingId = item?.id || null
|
||||
relationWarning.value = ''
|
||||
if (!item) {
|
||||
if (derivedCustomerSource.value === 'booking') { draft.customerId = null; draft.customerName = null; derivedCustomerSource.value = null }
|
||||
return
|
||||
}
|
||||
applyRelatedCustomer(item.customerId, item.customerName, 'booking')
|
||||
}
|
||||
async function loadOptions() {
|
||||
optionsLoading.value = true; optionsError.value = ''
|
||||
const results = await Promise.allSettled([
|
||||
@@ -83,24 +109,27 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
|
||||
|
||||
<template>
|
||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
|
||||
<el-alert v-if="resource" type="info" :closable="false" class="relation-alert">关联资源:{{ relationText }};柜型由关联柜体返回(只读)</el-alert>
|
||||
<el-alert type="info" :closable="false" class="relation-alert">订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约;选择后仅用于补全快照和一致性检查。</el-alert>
|
||||
<el-alert v-if="resource" type="info" :closable="false" class="relation-alert">历史关联快照:{{ relationText }}</el-alert>
|
||||
<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>
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24"><el-divider content-position="left">关联资源</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="关联柜次"><el-select :model-value="draft.containerBookingId" filterable clearable placeholder="请选择柜次" :loading="optionsLoading" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-for="item in containers" :key="item.id" :label="`柜次号:${item.cabinetNo || item.bookingNo || '待生成'} / 集装箱号:${item.containerNo || '待录入'}`" :value="item.id" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="关联订舱"><el-select :model-value="draft.oceanBookingId" filterable clearable placeholder="请选择订舱单" :loading="optionsLoading" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-for="item in bookings" :key="item.id" :label="item.bookingNo || `订舱 ${item.id}`" :value="item.id" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="客户"><el-select :model-value="draft.customerId" filterable clearable placeholder="请选择客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-if="draft.customerId && !selectableCustomers.some((item) => String(item.id) === String(draft.customerId))" :label="draft.customerName || '当前客户'" :value="String(draft.customerId)" /><el-option v-for="item in selectableCustomers" :key="String(item.id)" :label="customerLabel(item)" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">基本信息</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="客户" prop="customerId"><el-select :model-value="draft.customerId" filterable clearable placeholder="请选择客户" :loading="optionsLoading" no-data-text="暂无启用的客户" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-if="draft.customerId && !selectableCustomers.some((item) => String(item.id) === String(draft.customerId))" :label="draft.customerName || '历史客户(需重新选择)'" :value="String(draft.customerId)" disabled /><el-option v-for="item in selectableCustomers" :key="String(item.id)" :label="customerLabel(item)" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="承运商" prop="carrierId"><el-select :model-value="draft.carrierId" filterable clearable placeholder="请选择承运商" :loading="optionsLoading" no-data-text="暂无可用拖车供应商" style="width:100%" @update:model-value="selectCarrier(String($event || ''))"><el-option v-if="draft.carrierId && !selectableCarriers.some((item) => String(item.id) === String(draft.carrierId))" :label="draft.carrierName || '当前承运商(需重新选择)'" :value="String(draft.carrierId)" disabled /><el-option v-for="item in selectableCarriers" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="预计装箱时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计装箱时间" style="width:100%" /></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">可选关联</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="关联柜次(可选)"><el-select :model-value="draft.containerBookingId" filterable clearable placeholder="可选择柜次辅助补全" :loading="optionsLoading" no-data-text="暂无可关联柜次" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-for="item in containers" :key="item.id" :label="`柜次号:${item.cabinetNo || item.bookingNo || '待生成'} / 集装箱号:${item.containerNo || '待录入'}`" :value="item.id" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="关联订舱(可选)"><el-select :model-value="draft.oceanBookingId" filterable clearable placeholder="可选择订舱辅助补全" :loading="optionsLoading" no-data-text="暂无可关联订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-for="item in bookings" :key="item.id" :label="item.bookingNo || '订舱号缺失'" :value="item.id" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-select v-model="draft.warehouseId" filterable clearable placeholder="请选择仓库" :loading="optionsLoading" style="width:100%"><el-option v-for="item in selectableWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="派车单号"><el-input :model-value="resource.appointmentNo || ''" disabled /></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">运输与时间计划</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="承运商" prop="carrierId"><el-select :model-value="draft.carrierId" filterable clearable placeholder="请选择承运商" :loading="optionsLoading" no-data-text="暂无可用拖车供应商" style="width:100%" @update:model-value="selectCarrier(String($event || ''))"><el-option v-if="draft.carrierId && !selectableCarriers.some((item) => String(item.id) === String(draft.carrierId))" :label="draft.carrierName || '当前承运商(需重新选择)'" :value="String(draft.carrierId)" disabled /><el-option v-for="item in selectableCarriers" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" placeholder="请输入车队联系人" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="车队电话" prop="fleetPhone"><el-input v-model="draft.fleetPhone" placeholder="请输入车队联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="司机"><el-input v-model="draft.driverName" placeholder="请输入司机姓名" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="司机电话" prop="driverPhone"><el-input v-model="draft.driverPhone" placeholder="请输入司机联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="司机身份证号"><el-input v-model="draft.driverIdCard" placeholder="请输入司机身份证号" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="车头号"><el-input v-model="draft.vehicleNo" placeholder="请输入车牌或车头号" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="预计装箱时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计装箱时间" style="width:100%" /></el-form-item></el-col>
|
||||
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="实际装箱时间"><el-input :model-value="resource.actualLoadingAt || ''" disabled /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="件数" prop="packageCount"><el-input v-model="draft.packageCount" inputmode="numeric" placeholder="请输入件数" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="8"><el-form-item label="毛重(kg)" prop="grossWeightKg"><el-input v-model="draft.grossWeightKg" inputmode="decimal" placeholder="请输入毛重" /></el-form-item></el-col>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentCreateView.vue'), 'utf8')
|
||||
|
||||
describe('TruckAppointmentCreateView independent creation contract', () => {
|
||||
it('starts with optional associations cleared and keeps required business fields explicit', () => {
|
||||
expect(source).toContain('customerId: null, carrierId: null')
|
||||
expect(source).toContain('oceanBookingId: null, containerBookingId: null, loadingPlanId: null')
|
||||
expect(source).toContain('plannedLoadingAt: null')
|
||||
expect(source).toContain('createTruckAppointment({ ...form })')
|
||||
})
|
||||
|
||||
it('keeps the form visible and exposes the real backend error details', () => {
|
||||
expect(source).toContain("getApiErrorDetails(err, '创建失败,请稍后重试')")
|
||||
expect(source).toContain('<ApiErrorMeta :details="error" />')
|
||||
expect(source).toContain('<TruckAppointmentForm v-model="form"')
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@ import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
||||
import { createTruckAppointment } from '../api/platformTrucking'
|
||||
import type { SaveTruckAppointmentRequest } from '../types'
|
||||
const router = useRouter(); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null)
|
||||
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, plannedLoadingAt: null, payableAmount: '0.00', receivableAmount: '0.00', currency: 'RMB' })
|
||||
const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, customerId: null, carrierId: null, oceanBookingId: null, containerBookingId: null, loadingPlanId: null, 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>
|
||||
|
||||
@@ -12,4 +12,9 @@ describe('TruckAppointmentDetailView status contract', () => {
|
||||
expect(source).toContain("status !== resource.value?.status")
|
||||
expect(source).toContain("statuses.indexOf(status) === index")
|
||||
})
|
||||
|
||||
it('initializes independently required fields without inventing associations', () => {
|
||||
expect(source).toContain("customerId: null, carrierId: null, plannedLoadingAt: null")
|
||||
expect(source).toContain("error.value = getApiErrorDetails(err, '保存失败,请稍后重试')")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointm
|
||||
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
|
||||
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
||||
import { truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0 })
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, customerId: null, carrierId: null, plannedLoadingAt: null })
|
||||
const history = ref<TruckAppointmentStatusHistory[]>([])
|
||||
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
|
||||
async function loadRelated() { if (!resource.value) return; const [attachments, histories] = await Promise.all([listTruckAppointmentAttachments(id.value), listTruckAppointmentHistory(id.value)]); resource.value.attachments = attachments; history.value = histories }
|
||||
|
||||
@@ -142,9 +142,9 @@ export interface SaveTruckAppointmentRequest {
|
||||
containerBookingId?: string | null
|
||||
loadingPlanId?: string | null
|
||||
warehouseId?: string | null
|
||||
customerId?: string | null
|
||||
customerId: string | null
|
||||
customerName?: string | null
|
||||
carrierId?: string | null
|
||||
carrierId: string | null
|
||||
carrierName?: string | null
|
||||
fleetContact?: string | null
|
||||
fleetPhone?: string | null
|
||||
@@ -158,7 +158,7 @@ export interface SaveTruckAppointmentRequest {
|
||||
storeAddress?: string | null
|
||||
storeCity?: string | null
|
||||
storeZipcode?: string | null
|
||||
plannedLoadingAt?: string | null
|
||||
plannedLoadingAt: string | null
|
||||
transitMinutes?: number | string | null
|
||||
packageCount?: number | string | null
|
||||
grossWeightKg?: string | null
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import OperationErrorToast from './OperationErrorToast.vue'
|
||||
|
||||
describe('OperationErrorToast', () => {
|
||||
beforeEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('shows an operation error as a dismissible temporary message without page markup', async () => {
|
||||
const error = vi.spyOn(ElMessage, 'error').mockImplementation(() => ({ close: vi.fn() }) as never)
|
||||
const wrapper = mount(OperationErrorToast, { props: { details: { message: '箱号不存在', code: 'A0404' } } })
|
||||
|
||||
expect(error).toHaveBeenCalledWith({ message: '箱号不存在', showClose: true, duration: 4500 })
|
||||
expect(wrapper.html()).toBe('')
|
||||
|
||||
await wrapper.setProps({ details: { message: '保存失败', code: 'A0500' } })
|
||||
expect(error).toHaveBeenLastCalledWith({ message: '保存失败', showClose: true, duration: 4500 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
details?: ApiErrorDetails | null
|
||||
message?: string | null
|
||||
}>(), {
|
||||
details: null,
|
||||
message: null,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.details?.message || props.message || '',
|
||||
(message, previousMessage) => {
|
||||
if (!message || message === previousMessage) return
|
||||
ElMessage.error({ message, showClose: true, duration: 4500 })
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template></template>
|
||||
Reference in New Issue
Block a user