This commit is contained in:
2026-08-04 21:01:39 +08:00
parent 88bd939170
commit 5cb1ca307d
77 changed files with 9914 additions and 131 deletions
+17
View File
@@ -18,6 +18,23 @@ describe('admin router access metadata', () => {
expect(warehouseRoute?.meta.permission).toBe('platform:warehouse:list')
})
it('protects phase-two inbound operations and master data with deployed permissions', () => {
const expected = [
['inbound-orders', '/inbounds', 'platform:firstleg:inbound:list'],
['inbound-order-detail', '/inbounds/:inboundId', 'platform:firstleg:inbound:view'],
['fba-customer-rules', '/first-leg-pricing/customer-rules', 'firstleg:fba-pricing:rule:view'],
['fba-carrier-rates', '/first-leg-pricing/carrier-rates', 'firstleg:fba-pricing:rate:view'],
['locations', '/master-data/locations', 'platform:location:list'],
['pallets', '/master-data/pallets', 'platform:pallet:list'],
]
for (const [name, path, permission] of expected) {
const route = router.getRoutes().find((item) => item.name === name)
expect(route?.path).toBe(path)
expect(route?.meta.permission).toBe(permission)
}
})
it('protects express company records with a stable master-data permission', () => {
const route = router.getRoutes().find((item) => item.name === 'express-companies')
+6
View File
@@ -4,8 +4,11 @@ import { decideRouteAccess } from '@/core/routing/accessDecision'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { downloadRoutes } from '@/modules/downloads/routes'
import { firstLegOrderRoutes } from '@/modules/first-leg-orders/routes'
import { inboundRoutes } from '@/modules/inbounds/routes'
import { fbaPricingRoutes } from '@/modules/fba-pricing/routes'
import { iamRoutes } from '@/modules/iam/routes'
import { masterDataRoutes } from '@/modules/master-data/routes'
import { warehouseResourceRoutes } from '@/modules/warehouse-resources/routes'
import { useAuthStore } from '@/core/auth/auth'
import { customerRoutes } from '@/modules/customers/routes'
import { warehouseRoutes } from '@/modules/warehouses/routes'
@@ -35,6 +38,9 @@ const router = createRouter({
...customerRoutes,
...warehouseRoutes,
...firstLegOrderRoutes,
...inboundRoutes,
...fbaPricingRoutes,
...warehouseResourceRoutes,
...masterDataRoutes,
...downloadRoutes,
...iamRoutes,
+25
View File
@@ -129,6 +129,31 @@ describe('admin auth session', () => {
expect(auth.isAuthenticated).toBe(true)
})
it('refreshes a valid persisted session so newly granted permissions appear after reload', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
const refreshedToken = token({
permissions: ['platform:warehouse:list', 'platform:firstleg:inbound:list'],
})
mocks.post.mockResolvedValue(loginResponse(refreshedToken, 'refresh-rotated'))
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue([
'platform:warehouse:list',
'platform:firstleg:inbound:list',
])
const auth = useAuthStore()
await expect(auth.restoreSession()).resolves.toBe(true)
expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' })
expect([...auth.permissionCodes]).toEqual([
'platform:warehouse:list',
'platform:firstleg:inbound:list',
])
expect(localStorage.getItem('cc-admin-permission-codes'))
.toBe('["platform:warehouse:list","platform:firstleg:inbound:list"]')
})
it('does not let an in-flight refresh restore a session after logout', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
+9 -1
View File
@@ -162,7 +162,15 @@ export const useAuthStore = defineStore('admin-auth', () => {
}
async function restoreSession() {
if (isAuthenticated.value) return true
if (isAuthenticated.value) {
if (!refreshToken.value) return true
try {
await refreshSession()
return true
} catch {
return false
}
}
if (!refreshToken.value) return false
try {
await refreshSession()
+12 -1
View File
@@ -8,15 +8,26 @@ import {
} from './adminNavigation'
describe('adminNavigation', () => {
it('keeps only implemented phase-one menus visible while retaining prototype nodes as comments', () => {
it('exposes implemented phase-one and phase-two menus while retaining future prototype nodes as comments', () => {
expect(adminNavigation.map((item) => item.title)).toEqual([
'客户管理',
'国内仓',
'头程订单',
'头程计费',
'通用',
'系统管理',
])
expect(adminNavigation[0].children?.map((item) => item.title)).toEqual(['客户列表'])
expect(adminNavigation.find((item) => item.key === 'domestic-warehouse')?.children?.map((item) => item.title)).toEqual([
'仓库管理',
'入库单',
'库位管理',
'托盘管理',
])
expect(adminNavigation.find((item) => item.key === 'first-leg-pricing')?.children?.map((item) => item.title)).toEqual([
'客户计费规则',
'船司 FBA 价格',
])
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toContain('快递公司')
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toContain('下载中心')
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
+14 -1
View File
@@ -3,6 +3,7 @@ import {
Building2,
ContactRound,
ClipboardList,
CircleDollarSign,
Database,
Download,
KeyRound,
@@ -52,7 +53,9 @@ export const adminNavigation: AdminNavigationNode[] = [
iconColor: '#52c41a',
children: [
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
// { key: 'inbound-list', title: '入库单列表', icon: ClipboardList, path: '/prototype/domestic-warehouse/inbound-list' },
{ key: 'inbound-list', title: '入库单', icon: ClipboardList, path: '/inbounds', permission: 'platform:firstleg:inbound:list' },
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
{ key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' },
// { key: 'inbound-dedup', title: '入库单测量', icon: ClipboardList, path: '/prototype/domestic-warehouse/inbound-dedup' },
// { key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/prototype/domestic-warehouse/inbound-measure' },
// { key: 'inbound-receive', title: '接收审核', icon: FileCheck2, path: '/prototype/domestic-warehouse/inbound-receive' },
@@ -68,6 +71,16 @@ export const adminNavigation: AdminNavigationNode[] = [
path: '/orders/first-leg',
permission: 'platform:order:list',
},
{
key: 'first-leg-pricing',
title: '头程计费',
icon: CircleDollarSign,
iconColor: '#13a8a8',
children: [
{ key: 'fba-customer-rules', title: '客户计费规则', icon: Database, path: '/first-leg-pricing/customer-rules', permission: 'firstleg:fba-pricing:rule:view' },
{ key: 'fba-carrier-rates', title: '船司 FBA 价格', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
],
},
/* {
key: 'overseas-delivery',
title: '海外派送',
@@ -199,6 +199,10 @@ describe('CustomersView', () => {
})
it('saves customer data and selected qualifications with one action', async () => {
vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-legal', tenantId: fullTenant.id, qualificationType: 'LEGAL_PERSON_ID',
certificateNo: null, documentUrl: '/files/legal-person.png', status: 'PENDING', reviewComment: null, validTo: null,
}])
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
+34 -9
View File
@@ -108,10 +108,7 @@ const rules: FormRules = {
tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }],
legalRepresentative: [{ required: true, message: '请输入法定代表人姓名', trigger: 'blur' }],
unifiedSocialCreditCode: [
{ required: true, message: '请输入统一社会信用代码', trigger: 'blur' },
{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' },
],
unifiedSocialCreditCode: [{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' }],
contactName: [{ required: true, message: '请输入联系人姓名', trigger: 'blur' }],
contactPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
contactEmail: [{ type: 'email', message: '请输入有效邮箱地址', trigger: 'blur' }],
@@ -151,6 +148,8 @@ function newQualificationDrafts(): QualificationDraft[] {
}))
}
const qualifications = ref<QualificationDraft[]>(newQualificationDrafts())
const creditCodeEstablished = ref(false)
const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
const customerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', minWidth: 190, slotName: 'actions' },
@@ -174,6 +173,20 @@ const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_
const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || ''))
const uploadingQualifications = computed(() => qualifications.value.some((item) => item.uploading))
const hasSelectedQualifications = computed(() => qualifications.value.some((item) => item.file))
const selectedBusinessLicense = computed(() => qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')?.file)
const creditCodeReadyForSave = computed(() => creditCodeEstablished.value
|| (Boolean(selectedBusinessLicense.value) && unifiedSocialCreditCodePattern.test(form.value.unifiedSocialCreditCode || '')))
const requiredQualificationPackageComplete = computed(() => creditCodeReadyForSave.value
&& qualifications.value.filter((item) => item.required).every((item) => item.uploaded || Boolean(item.file)))
const missingQualificationPackageItems = computed(() => {
const businessLicense = qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')
const legalPersonId = qualifications.value.find((item) => item.type === 'LEGAL_PERSON_ID')
const missing: string[] = []
if (!businessLicense?.uploaded && !businessLicense?.file) missing.push('营业执照')
if (!creditCodeReadyForSave.value) missing.push('18位统一社会信用代码(需随营业执照保存)')
if (!legalPersonId?.uploaded && !legalPersonId?.file) missing.push('法人身份证')
return missing
})
const hasUnsavedChanges = computed(() => Boolean(editingId.value) && JSON.stringify(form.value) !== savedFormSnapshot.value)
const pendingReviewByTenantId = computed(() => new Map(pendingReviews.value.map((review) => [String(review.tenantId), review])))
function displayText(value?: string | null) { return value?.trim() || '—' }
@@ -302,7 +315,7 @@ function clearQualificationPreviews() {
}
function resetEditor() {
clearQualificationPreviews()
form.value = emptyForm(); qualifications.value = newQualificationDrafts(); qualificationLoadError.value = ''; savedFormSnapshot.value = ''
form.value = emptyForm(); qualifications.value = newQualificationDrafts(); creditCodeEstablished.value = false; qualificationLoadError.value = ''; savedFormSnapshot.value = ''
}
function openCreate() {
editorLoadSequence += 1
@@ -352,6 +365,7 @@ async function openEdit(row: PlatformTenantResource) {
if (sequence !== editorLoadSequence || editingId.value !== id || !editorOpen.value) return
editorStatus.value = tenant.status
form.value = mapTenantToForm(tenant)
creditCodeEstablished.value = unifiedSocialCreditCodePattern.test(tenant.unifiedSocialCreditCode || '')
savedFormSnapshot.value = JSON.stringify(form.value)
await loadEditorQualifications(row.id, sequence)
await nextTick(() => formRef.value?.clearValidate())
@@ -430,6 +444,7 @@ async function uploadSelectedQualifications(tenantId: string) {
draft.uploaded,
)
draft.uploaded = true
if (draft.type === 'BUSINESS_LICENSE') creditCodeEstablished.value = true
draft.uploadedName = draft.file?.name || '已登记文件'
draft.documentUrl = uploaded.documentUrl
draft.file = null
@@ -442,6 +457,11 @@ async function uploadSelectedQualifications(tenantId: string) {
async function saveCustomer() {
if (!formRef.value || !(await formRef.value.validate().catch(() => false))) return
const qualificationPackageTouched = hasSelectedQualifications.value || Boolean(form.value.unifiedSocialCreditCode)
if (qualificationPackageTouched && !requiredQualificationPackageComplete.value) {
ElMessage.warning(`请完整保存必填业务项:${missingQualificationPackageItems.value.join('、')}`)
return
}
saving.value = true
try {
const hadSelectedQualifications = hasSelectedQualifications.value
@@ -450,7 +470,7 @@ async function saveCustomer() {
const { unifiedSocialCreditCode: _immutable, ...payload } = form.value
saved = await updatePlatformTenant(editingId.value, payload)
} else {
saved = await createPlatformTenant(form.value)
saved = await createPlatformTenant({ ...form.value, unifiedSocialCreditCode: undefined })
editingId.value = String(saved.id)
}
editorStatus.value = saved.status
@@ -459,7 +479,7 @@ async function saveCustomer() {
if (failures.length) {
ElMessage.warning(`客户资料已保存,但${failures.join('、')}上传失败;失败文件已保留,请修正后再次保存`)
} else if (hadSelectedQualifications) {
ElMessage.success('客户资料和已选资质已保存,尚未提交审核')
ElMessage.success('营业执照、统一社会信用代码和法人身份证已完整保存,尚未提交审核')
} else {
ElMessage.success('客户资料已保存,尚未提交审核')
}
@@ -477,6 +497,10 @@ async function submitReview() {
if (hasUnsavedChanges.value) { ElMessage.warning('客户资料有未保存的修改,请先保存资料'); return }
if (uploadingQualifications.value) { ElMessage.warning('资质文件正在上传,请稍候再提交审核'); return }
if (hasSelectedQualifications.value) { ElMessage.warning('存在尚未保存的已选文件,请先点击“保存资料和资质”'); return }
if (!creditCodeEstablished.value || !unifiedSocialCreditCodePattern.test(form.value.unifiedSocialCreditCode || '')) {
ElMessage.warning('请先完整保存营业执照、18位统一社会信用代码和法人身份证')
return
}
const missing = qualifications.value.filter((item) => item.required && !item.uploaded)
for (const item of missing) item.error = `${item.label}为提交审核必填文件`
if (missing.length) { ElMessage.warning(`请先上传${missing.map((item) => item.label).join('、')}`); return }
@@ -627,7 +651,6 @@ onMounted(async () => {
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
<el-form-item label="法定代表人" prop="legalRepresentative" required><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名" maxlength="64" /></el-form-item>
<el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required><el-input v-model="form.unifiedSocialCreditCode" :disabled="Boolean(editingId)" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /></el-form-item>
<el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item>
<el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item>
<el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item>
@@ -643,7 +666,8 @@ onMounted(async () => {
<el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" /></el-form-item>
</div><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></div>
<div class="form-section qualification-section"><div class="qualification-heading"><div><h3>资质文件</h3><p>先选择并上传文件;上传成功不会创建审核单,点击“提交审核”并确认后才会进入审核。</p></div><el-tag v-if="editorStatus" :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
<div class="form-section qualification-section"><div class="qualification-heading"><div><h3>企业认证必填业务项</h3><p>营业执照、18位统一社会信用代码、法人身份证需共同完整保存;货代资质证书和银行开户许可证选填。</p></div><el-tag v-if="editorStatus" :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
<el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><el-input v-model="form.unifiedSocialCreditCode" :disabled="creditCodeEstablished" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照文件共同保存。' }}</small></el-form-item>
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
<div class="qualification-table" role="table" aria-label="客户资质文件">
<div class="qualification-row qualification-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">上传文件</span><span role="columnheader">有效期</span></div>
@@ -706,6 +730,7 @@ onMounted(async () => {
.customer-company-cell { display:flex; min-width:0; align-items:center; gap:10px; } .customer-company-cell > span { display:grid; width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:7px; background:#eef4ff; color:var(--cc-primary); } .customer-company-cell strong { overflow:hidden; text-overflow:ellipsis; }
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:8px; } .capital-input :deep(.el-input-number),.capital-input :deep(.el-select) { width:100%; }
.qualification-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; } .qualification-heading h3 { margin-bottom:4px; } .qualification-heading p,.qualification-hint { margin:0; color:var(--cc-muted); font-size:12px; line-height:1.6; } .qualification-hint { margin-top:12px; } .qualification-hint span,.qualification-label > span { color:var(--el-color-danger); }
.qualification-credit-code { max-width:420px; margin-top:16px; } .qualification-credit-code small { display:block; margin-top:6px; color:var(--cc-muted); font-size:11px; line-height:1.5; }
.qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:180px minmax(300px,1fr) 180px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; min-height:68px; align-items:center; padding:12px 14px; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:12px; font-weight:600; } .qualification-header > * { min-height:44px; }
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
.qualification-optional { padding:2px 5px; border-radius:4px; background:var(--el-fill-color-light); color:var(--cc-muted); font-size:10px; font-weight:500; line-height:1.4; }
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
}))
vi.mock('@/shared/api/http', () => ({
http: mocks,
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
import {
createPlatformCarrierRateVersion,
getPlatformCarrierRate,
listPlatformCarrierRates,
retirePlatformCarrierRate,
} from './platformCarrierRates'
const payload = {
id: '9007199254740995',
tenantId: '10001',
rateCode: 'RATE-US-WEST',
rateVersion: 2,
originWarehouseId: '20001',
originWarehouseName: '义乌收货仓',
businessType: 'FBA_SEA',
channelCode: 'US-WEST-SEA',
carrierId: '30001',
carrierName: '示例船司',
destinationCode: 'ONT8',
chargeUnit: 'KG' as const,
unitPrice: 12.5,
currency: 'CNY',
additionalFee: 80,
effectiveFrom: '2026-08-01T00:00:00',
effectiveTo: null,
status: 'ACTIVE' as const,
remark: null,
createdBy: '10',
createdAt: '2026-08-01T10:00:00',
updatedBy: null,
updatedAt: null,
version: 1,
}
describe('platformCarrierRates api', () => {
beforeEach(() => vi.clearAllMocks())
it('queries the real carrier-rate path and keeps every long id as a string', async () => {
mocks.get.mockResolvedValue({ data: [payload] })
const result = await listPlatformCarrierRates({ customerId: ' 10001 ', rateCode: ' RATE-US ' })
expect(mocks.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates', {
params: { tenantId: '10001', rateCode: 'RATE-US' },
})
expect(result[0]).toMatchObject({
id: '9007199254740995',
tenantId: '10001',
originWarehouseId: '20001',
carrierId: '30001',
})
})
it('omits the compatibility tenant parameter for an all-customer list query', async () => {
mocks.get.mockResolvedValue({ data: [payload] })
await listPlatformCarrierRates()
await listPlatformCarrierRates({ customerId: '', rateCode: ' ' })
expect(mocks.get).toHaveBeenNthCalledWith(1, '/platform/v1/fba-pricing/carrier-rates', { params: {} })
expect(mocks.get).toHaveBeenNthCalledWith(2, '/platform/v1/fba-pricing/carrier-rates', { params: {} })
})
it('loads a version detail with tenant scope', async () => {
mocks.get.mockResolvedValue({ data: payload })
await getPlatformCarrierRate('10001', '9001')
expect(mocks.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/9001', {
params: { tenantId: '10001' },
})
})
it('creates immutable versions and retires an active version', async () => {
mocks.post.mockResolvedValue({ data: payload })
mocks.delete.mockResolvedValue({ data: null })
const request = {
rateCode: 'RATE-US-WEST',
originWarehouseId: '20001',
originWarehouseName: '义乌收货仓',
businessType: 'FBA_SEA',
channelCode: 'US-WEST-SEA',
carrierId: '30001',
carrierName: '示例船司',
destinationCode: 'ONT8',
chargeUnit: 'KG' as const,
unitPrice: 12.5,
currency: 'CNY',
additionalFee: 80,
effectiveFrom: '2026-08-01T00:00:00',
effectiveTo: null,
remark: null,
}
await createPlatformCarrierRateVersion('10001', request)
await retirePlatformCarrierRate('10001', '9001')
expect(mocks.post).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/versions', request, {
params: { tenantId: '10001' },
})
expect(mocks.delete).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/9001', {
params: { tenantId: '10001' },
})
})
})
@@ -0,0 +1,124 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type CarrierRateChargeUnit = 'KG' | 'CBM'
export type CarrierRateStatus = 'ACTIVE' | 'INACTIVE'
export interface PlatformCarrierRateResource {
id: string
tenantId: string
rateCode: string
rateVersion: number
originWarehouseId: string
originWarehouseName: string
businessType: string
channelCode: string
carrierId: string
carrierName: string
destinationCode: string
chargeUnit: CarrierRateChargeUnit
unitPrice: number
currency: string
additionalFee: number
effectiveFrom: string
effectiveTo: string | null
status: CarrierRateStatus
remark: string | null
createdBy: string | null
createdAt: string | null
updatedBy: string | null
updatedAt: string | null
version: number | null
}
interface PlatformCarrierRatePayload extends Omit<PlatformCarrierRateResource,
'id' | 'tenantId' | 'originWarehouseId' | 'carrierId' | 'createdBy' | 'updatedBy'> {
id: string | number
tenantId: string | number
originWarehouseId: string | number
carrierId: string | number
createdBy?: string | number | null
updatedBy?: string | number | null
}
export interface CreatePlatformCarrierRateVersionRequest {
rateCode: string
originWarehouseId: string
originWarehouseName: string
businessType: string
channelCode: string
carrierId: string
carrierName: string
destinationCode: string
chargeUnit: CarrierRateChargeUnit
unitPrice: number
currency: string
additionalFee: number
effectiveFrom: string
effectiveTo: string | null
remark: string | null
}
export interface PlatformCarrierRateQuery {
customerId?: string
rateCode?: string
}
const basePath = '/platform/v1/fba-pricing/carrier-rates'
function nullableId(value: string | number | null | undefined) {
return value == null ? null : String(value)
}
function normalizeCarrierRate(value: PlatformCarrierRatePayload): PlatformCarrierRateResource {
return {
...value,
id: String(value.id),
tenantId: String(value.tenantId),
originWarehouseId: String(value.originWarehouseId),
carrierId: String(value.carrierId),
createdBy: nullableId(value.createdBy),
updatedBy: nullableId(value.updatedBy),
effectiveTo: value.effectiveTo ?? null,
remark: value.remark ?? null,
createdAt: value.createdAt ?? null,
updatedAt: value.updatedAt ?? null,
version: value.version ?? null,
}
}
export async function listPlatformCarrierRates(query: PlatformCarrierRateQuery = {}) {
const customerId = query.customerId?.trim()
const rateCode = query.rateCode?.trim()
const response = await http.get<ApiEnvelope<PlatformCarrierRatePayload[]>, ApiEnvelope<PlatformCarrierRatePayload[]>>(
basePath,
{ params: { ...(customerId ? { tenantId: customerId } : {}), ...(rateCode ? { rateCode } : {}) } },
)
return unwrapApiResponse(response).map(normalizeCarrierRate)
}
export async function getPlatformCarrierRate(tenantId: string, id: string) {
const response = await http.get<ApiEnvelope<PlatformCarrierRatePayload>, ApiEnvelope<PlatformCarrierRatePayload>>(
`${basePath}/${id}`,
{ params: { tenantId } },
)
return normalizeCarrierRate(unwrapApiResponse(response))
}
export async function createPlatformCarrierRateVersion(
tenantId: string,
payload: CreatePlatformCarrierRateVersionRequest,
) {
const response = await http.post<ApiEnvelope<PlatformCarrierRatePayload>, ApiEnvelope<PlatformCarrierRatePayload>>(
`${basePath}/versions`,
payload,
{ params: { tenantId } },
)
return normalizeCarrierRate(unwrapApiResponse(response))
}
export async function retirePlatformCarrierRate(tenantId: string, id: string) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, {
params: { tenantId },
})
return unwrapApiResponse(response)
}
@@ -0,0 +1,98 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(join(process.cwd(), 'src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue'), 'utf8')
const queryToolbarSource = readFileSync(join(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8')
describe('CarrierRatesView source contract', () => {
it('keeps operation column first and covers every required business field', () => {
const customerColumn = "key: 'customer', label: '所属企业'"
const destinationColumn = "key: 'destinationCode', prop: 'destinationCode'"
expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf(customerColumn))
expect(source.indexOf(customerColumn)).toBeLessThan(source.indexOf(destinationColumn))
expect(source.indexOf(destinationColumn)).toBeLessThan(source.indexOf("key: 'rate'"))
for (const label of ['起运仓', '业务类型', '渠道编码', '船司', 'FBA 目的仓', '计费单位', '单价', '币种', '固定附加费', '有效期', '备注', '生效时间', '结束时间']) {
expect(source).toContain(label)
}
expect(source).toContain("placeholder: 'FBA 目的仓编码'")
expect(source).toContain("placeholder: '渠道编码'")
})
it('implements immutable version actions with exact backend permissions', () => {
expect(source).toContain("firstleg:fba-pricing:rate:edit")
expect(source).toContain('创建版本')
expect(source).toContain('新版本')
expect(source).toContain('停用')
expect(source).not.toContain('编辑价格')
})
it('keeps the customer company scope in the query toolbar without exposing tenant concepts', () => {
expect(source).toContain("key: 'customerId'")
expect(source.indexOf("key: 'customerId'")).toBeLessThan(source.indexOf("key: 'rateCode'"))
expect(source).toContain("'aria-label': '所属企业'")
expect(source).toContain('listPlatformTenants')
expect(source).toContain('loadedCustomerId')
expect(source).not.toContain('PlatformTenantSelector')
expect(source).not.toContain('selectedTenantId')
expect(source).not.toContain('租户')
})
it('uses semantic compact filter widths and keeps the customer selection empty by default', () => {
for (const field of [
"key: 'customerId',\n type: 'select',\n width: 280",
"key: 'rateCode', type: 'input', width: 220",
"key: 'destinationCode', type: 'input', width: 220",
"key: 'channelCode', type: 'input', width: 220",
"key: 'chargeUnit', type: 'select', width: 180",
"key: 'status', type: 'select', width: 160",
]) expect(source).toContain(field)
expect(source).toContain("customerId: ''")
expect(source).toContain("placeholder: customerLoadError.value ? '企业列表加载失败' : '全部客户'")
expect(source).not.toContain(':search-on-reset="false"')
expect(source).not.toContain('请先选择所属企业')
expect(source).not.toContain('请选择所属企业并查询价格数据')
expect(source).not.toContain('customers.value[0]')
})
it('loads all customers initially and after reset while preserving row scope for record actions', () => {
expect(source).toContain('void loadRateData()')
expect(source).toContain("listPlatformCarrierRates(customerId ? { customerId } : {})")
expect(source).toContain('loadRateData(customerId)')
expect(source).toContain('getPlatformCarrierRate(String(row.tenantId), row.id)')
expect(source).toContain('retirePlatformCarrierRate(String(row.tenantId), row.id)')
expect(source).toContain('customerLabel(detail.tenantId)')
})
it('inherits QueryToolbar wrapping and full-width mobile controls', () => {
expect(queryToolbarSource).toContain('flex-wrap: wrap')
expect(queryToolbarSource).toContain('@media (max-width: 720px)')
expect(queryToolbarSource).toContain('width: 100%')
expect(source).not.toContain('--query-field-width')
})
it('guards unsaved work, failures and narrow screens', () => {
expect(source).toContain('useUnsavedWorkRegistration')
expect(source).toContain('LoadErrorState')
expect(source).toContain('ApiErrorMeta')
expect(source).toContain('@media (max-width: 720px)')
})
it('reuses the first-leg order list primitives for status, empty state and pagination', () => {
expect(source).toContain("import StatusBadge from '@/shared/components/StatusBadge.vue'")
expect(source).toContain('<StatusBadge :status="statusLabel(')
expect(source).toContain('<template #empty><el-empty')
expect(source).toContain('<div class="business-page">')
expect(source).not.toContain('<QueryToolbar v-model="filters" class="surface-panel"')
expect(source).not.toContain('<template #pagination-left>')
expect(source).not.toContain('<el-tag')
})
it('does not create route or navigation ownership in the page task', () => {
expect(source).not.toContain('createRouter')
expect(source).not.toContain('adminNavigation')
for (const unsupportedCapability of ['导入', '导出', '全部船期', '参考时效', '最晚进仓时间']) {
expect(source).not.toContain(unsupportedCapability)
}
})
})
@@ -0,0 +1,641 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { CalendarClock, Eye, Plus, Power, Ship, Warehouse } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { listPlatformTenants, type PlatformTenantResource as PlatformCustomerResource } from '@/modules/iam'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import {
createPlatformCarrierRateVersion,
getPlatformCarrierRate,
listPlatformCarrierRates,
retirePlatformCarrierRate,
type CarrierRateChargeUnit,
type CarrierRateStatus,
type CreatePlatformCarrierRateVersionRequest,
type PlatformCarrierRateResource,
} from '../api/platformCarrierRates'
type RateFilters = QueryModel & {
customerId: string
rateCode: string
destinationCode: string
channelCode: string
chargeUnit: CarrierRateChargeUnit | ''
status: CarrierRateStatus | ''
}
interface RateForm {
rateCode: string
originWarehouseId: string
businessType: string
channelCode: string
carrierId: string
destinationCode: string
chargeUnit: CarrierRateChargeUnit
unitPrice: number | null
currency: string
additionalFee: number | null
effectiveFrom: string
effectiveTo: string
remark: string
}
const access = useAccessPreviewStore()
const customers = ref<PlatformCustomerResource[]>([])
const customersLoading = ref(false)
const customerLoadError = ref<ApiErrorDetails | null>(null)
const loadedCustomerId = ref<string | null>(null)
const rows = ref<PlatformCarrierRateResource[]>([])
const warehouses = ref<PlatformWarehouseResource[]>([])
const carriers = ref<PlatformPartnerResource[]>([])
const loading = ref(false)
const masterDataLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(20)
const editorOpen = ref(false)
const detailOpen = ref(false)
const detailLoading = ref(false)
const saving = ref(false)
const detail = ref<PlatformCarrierRateResource | null>(null)
const retiringIds = ref(new Set<string>())
const editorCustomerId = ref('')
const formRef = ref<FormInstance>()
let loadSequence = 0
let masterDataSequence = 0
const emptyForm = (): RateForm => ({
rateCode: '',
originWarehouseId: '',
businessType: '',
channelCode: '',
carrierId: '',
destinationCode: '',
chargeUnit: 'KG',
unitPrice: null,
currency: 'CNY',
additionalFee: 0,
effectiveFrom: '',
effectiveTo: '',
remark: '',
})
const form = reactive<RateForm>(emptyForm())
const editorLabel = computed(() => form.rateCode ? `创建 ${form.rateCode} 的新版本` : '新增船司 FBA 价格版本')
const requiredPermission = computed(() => 'firstleg:fba-pricing:rate:edit')
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'carrier-rate-version-editor',
label: editorLabel,
isOpen: editorOpen,
model: form,
requiredPermission,
})
const filters = ref<RateFilters>({ customerId: '', rateCode: '', destinationCode: '', channelCode: '', chargeUnit: '', status: '' })
const appliedFilters = ref<RateFilters>({ ...filters.value })
const customerSwitchDisabled = computed(() => editorOpen.value || detailOpen.value || saving.value || detailLoading.value || retiringIds.value.size > 0)
const queryFields = computed<QueryField[]>(() => [
{
key: 'customerId',
type: 'select',
width: 280,
placeholder: customerLoadError.value ? '企业列表加载失败' : '全部客户',
props: { 'aria-label': '所属企业', filterable: true, disabled: customersLoading.value || Boolean(customerLoadError.value) || customerSwitchDisabled.value },
options: customers.value.map((customer) => ({ value: String(customer.id), label: `${customer.tenantName}(${customer.tenantCode || customer.id})` })),
},
{ key: 'rateCode', type: 'input', width: 220, placeholder: '价格编码', props: { 'aria-label': '价格编码' } },
{ key: 'destinationCode', type: 'input', width: 220, placeholder: 'FBA 目的仓编码', props: { 'aria-label': 'FBA 目的仓编码' } },
{ key: 'channelCode', type: 'input', width: 220, placeholder: '渠道编码', props: { 'aria-label': '渠道编码' } },
{
key: 'chargeUnit', type: 'select', width: 180, placeholder: '全部计费单位', props: { 'aria-label': '计费单位' }, options: [
{ label: 'KG', value: 'KG' },
{ label: 'CBM', value: 'CBM' },
],
},
{
key: 'status', type: 'select', width: 160, placeholder: '全部状态', props: { 'aria-label': '价格状态' }, options: [
{ label: '生效中', value: 'ACTIVE' },
{ label: '已停用', value: 'INACTIVE' },
],
},
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
{ key: 'customer', label: '所属企业', minWidth: 200, slotName: 'customer' },
{ key: 'destinationCode', prop: 'destinationCode', label: 'FBA 目的仓', minWidth: 135 },
{ key: 'rate', label: '价格版本', minWidth: 180, slotName: 'rate' },
{ key: 'originWarehouseName', prop: 'originWarehouseName', label: '起运仓', minWidth: 170, showOverflowTooltip: true },
{ key: 'channelCode', prop: 'channelCode', label: '渠道编码', minWidth: 150, showOverflowTooltip: true },
{ key: 'carrier', label: '船司', minWidth: 170, slotName: 'carrier' },
{ key: 'businessType', prop: 'businessType', label: '业务类型', minWidth: 110 },
{ key: 'chargeUnit', prop: 'chargeUnit', label: '计费单位', width: 95 },
{ key: 'unitPrice', label: '单价', minWidth: 135, slotName: 'unitPrice' },
{ key: 'additionalFee', label: '固定附加费', minWidth: 135, slotName: 'additionalFee' },
{ key: 'currency', prop: 'currency', label: '币种', width: 80 },
{ key: 'period', label: '有效期', minWidth: 210, slotName: 'period' },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 190, showOverflowTooltip: true, formatter: (row: PlatformCarrierRateResource) => row.remark || '-' },
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
{ key: 'updatedAt', label: '更新时间', minWidth: 150, formatter: (row: PlatformCarrierRateResource) => formatDateTime(row.updatedAt) },
]
const originWarehouses = computed(() => warehouses.value.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'DOMESTIC'))
const fbaWarehouses = computed(() => warehouses.value.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'FBA'))
const activeCarriers = computed(() => carriers.value.filter((item) => item.status === 'ACTIVE'))
const filteredRows = computed(() => rows.value.filter((row) => {
const code = appliedFilters.value.rateCode.trim().toLowerCase()
const destinationCode = appliedFilters.value.destinationCode.trim().toLowerCase()
const channelCode = appliedFilters.value.channelCode.trim().toLowerCase()
return (!code || row.rateCode.toLowerCase().includes(code))
&& (!destinationCode || row.destinationCode.toLowerCase().includes(destinationCode))
&& (!channelCode || row.channelCode.toLowerCase().includes(channelCode))
&& (!appliedFilters.value.chargeUnit || row.chargeUnit === appliedFilters.value.chargeUnit)
&& (!appliedFilters.value.status || row.status === appliedFilters.value.status)
}))
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const activeCount = computed(() => rows.value.filter((row) => row.status === 'ACTIVE').length)
const loadErrorMessage = computed(() => loadError.value?.message || '')
function validateEffectiveTo(_rule: unknown, value: string, callback: (error?: Error) => void) {
if (!value || !form.effectiveFrom || new Date(value).getTime() > new Date(form.effectiveFrom).getTime()) callback()
else callback(new Error('结束时间必须晚于开始时间'))
}
const rules: FormRules<RateForm> = {
rateCode: [{ required: true, whitespace: true, message: '请输入价格编码', trigger: 'blur' }],
originWarehouseId: [{ required: true, message: '请选择起运仓', trigger: 'change' }],
businessType: [{ required: true, whitespace: true, message: '请输入业务类型', trigger: 'blur' }],
channelCode: [{ required: true, whitespace: true, message: '请输入渠道编码', trigger: 'blur' }],
carrierId: [{ required: true, message: '请选择船司', trigger: 'change' }],
destinationCode: [{ required: true, whitespace: true, message: '请选择或输入 FBA 目的仓编码', trigger: 'change' }],
chargeUnit: [{ required: true, message: '请选择计费单位', trigger: 'change' }],
unitPrice: [{ required: true, type: 'number', min: 0.000001, message: '单价必须大于 0', trigger: 'change' }],
currency: [
{ required: true, whitespace: true, message: '请输入币种', trigger: 'blur' },
{ pattern: /^[A-Z]{3}$/, message: '币种须为三个大写英文字母', trigger: 'blur' },
],
additionalFee: [{ required: true, type: 'number', min: 0, message: '附加费不能小于 0', trigger: 'change' }],
effectiveFrom: [{ required: true, message: '请选择生效时间', trigger: 'change' }],
effectiveTo: [{ validator: validateEffectiveTo, trigger: 'change' }],
}
watch(() => filters.value.customerId, (customerId) => {
if (customerId === loadedCustomerId.value) return
masterDataSequence += 1
warehouses.value = []
carriers.value = []
loadedCustomerId.value = null
page.value = 1
})
watch(
() => access.can('firstleg:fba-pricing:rate:edit'),
(canEdit) => {
if (canEdit || !editorOpen.value) return
editorOpen.value = false
Object.assign(form, emptyForm())
},
{ flush: 'sync' },
)
function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 16) : '-'
}
function formatMoney(value: number, currency: string) {
return `${currency} ${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`
}
function statusLabel(status: CarrierRateStatus) {
return status === 'ACTIVE' ? '启用' : '停用'
}
function customerLabel(customerId: string) {
const customer = customers.value.find((item) => String(item.id) === String(customerId))
return customer ? `${customer.tenantName}(${customer.tenantCode || '未设置编码'})` : '客户信息待加载'
}
function nullable(value: string) {
return value.trim() || null
}
async function loadCustomers() {
customersLoading.value = true
customerLoadError.value = null
try {
const records: PlatformCustomerResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
customers.value = records
} catch (error) {
customers.value = []
customerLoadError.value = getApiErrorDetails(error, '企业列表加载失败,暂时无法按所属企业筛选或显示企业名称;价格列表仍可查询')
} finally {
customersLoading.value = false
}
}
async function loadRateData(customerId = filters.value.customerId) {
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
const rateResult = await listPlatformCarrierRates(customerId ? { customerId } : {})
if (sequence !== loadSequence) return
rows.value = rateResult
} catch (error) {
if (sequence !== loadSequence) return
rows.value = []
loadError.value = getApiErrorDetails(error, '船司 FBA 价格加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) {
loading.value = false
}
}
}
async function loadCustomerMasterData(customerId: string) {
if (!customerId) return false
const sequence = ++masterDataSequence
masterDataLoading.value = true
try {
const [warehouseResult, partnerResult] = await Promise.all([
listPlatformWarehouses(customerId, 1, 200),
listPlatformPartners(customerId, { page: 1, size: 200, type: 'CARRIER', status: 'ACTIVE' }),
])
if (sequence !== masterDataSequence) return false
warehouses.value = warehouseResult.records.map((item) => ({ ...item, id: String(item.id), tenantId: String(item.tenantId) }))
carriers.value = partnerResult.records.map((item) => ({ ...item, id: String(item.id), tenantId: String(item.tenantId), dataScopeId: String(item.dataScopeId) }))
loadedCustomerId.value = customerId
return true
} catch (error) {
if (sequence === masterDataSequence) {
warehouses.value = []
carriers.value = []
loadedCustomerId.value = null
ElMessage.error(getApiErrorDetails(error, '所属企业的仓库或船司资料加载失败').message)
}
return false
} finally {
if (sequence === masterDataSequence) masterDataLoading.value = false
}
}
async function applyQuery(model: QueryModel) {
const customerId = String(model.customerId || '')
appliedFilters.value = {
customerId,
rateCode: String(model.rateCode || ''),
destinationCode: String(model.destinationCode || ''),
channelCode: String(model.channelCode || ''),
chargeUnit: (model.chargeUnit || '') as CarrierRateChargeUnit | '',
status: (model.status || '') as CarrierRateStatus | '',
}
page.value = 1
await Promise.all([
loadRateData(customerId),
customerId && loadedCustomerId.value !== customerId ? loadCustomerMasterData(customerId) : Promise.resolve(true),
])
}
function resetQuery(model: QueryModel) {
appliedFilters.value = {
customerId: String(model.customerId || ''),
rateCode: String(model.rateCode || ''),
destinationCode: String(model.destinationCode || ''),
channelCode: String(model.channelCode || ''),
chargeUnit: (model.chargeUnit || '') as CarrierRateChargeUnit | '',
status: (model.status || '') as CarrierRateStatus | '',
}
page.value = 1
}
function resetForm(rate?: PlatformCarrierRateResource) {
Object.assign(form, emptyForm(), rate ? {
rateCode: rate.rateCode,
originWarehouseId: rate.originWarehouseId,
businessType: rate.businessType,
channelCode: rate.channelCode,
carrierId: rate.carrierId,
destinationCode: rate.destinationCode,
chargeUnit: rate.chargeUnit,
unitPrice: rate.unitPrice,
currency: rate.currency,
additionalFee: rate.additionalFee,
effectiveFrom: '',
effectiveTo: '',
remark: rate.remark || '',
} : {})
}
async function openCreate(rate?: PlatformCarrierRateResource) {
const customerId = String(rate?.tenantId || filters.value.customerId || '')
if (!customerId) {
ElMessage.warning('新增价格版本需要先选择所属企业')
return
}
if (loadedCustomerId.value !== customerId) {
const loaded = await loadCustomerMasterData(customerId)
if (!loaded) return
}
editorCustomerId.value = customerId
resetForm(rate)
captureBaseline()
editorOpen.value = true
requestAnimationFrame(() => formRef.value?.clearValidate())
}
async function openDetail(row: PlatformCarrierRateResource) {
detailOpen.value = true
detailLoading.value = true
detail.value = null
try {
detail.value = await getPlatformCarrierRate(String(row.tenantId), row.id)
} catch (error) {
const meta = getApiErrorDetails(error, '价格版本详情加载失败')
ElMessage.error(meta.requestId ? `${meta.message}(requestId: ${meta.requestId})` : meta.message)
detailOpen.value = false
} finally {
detailLoading.value = false
}
}
function selectedOriginWarehouse() {
return originWarehouses.value.find((item) => String(item.id) === form.originWarehouseId)
}
function selectedCarrier() {
return activeCarriers.value.find((item) => String(item.id) === form.carrierId)
}
function buildPayload(): CreatePlatformCarrierRateVersionRequest | null {
const warehouse = selectedOriginWarehouse()
const carrier = selectedCarrier()
if (!warehouse || !carrier || form.unitPrice == null || form.additionalFee == null) return null
return {
rateCode: form.rateCode.trim(),
originWarehouseId: String(warehouse.id),
originWarehouseName: warehouse.warehouseName,
businessType: form.businessType.trim(),
channelCode: form.channelCode.trim(),
carrierId: String(carrier.id),
carrierName: carrier.partnerName,
destinationCode: form.destinationCode.trim().toUpperCase(),
chargeUnit: form.chargeUnit,
unitPrice: form.unitPrice,
currency: form.currency.trim().toUpperCase(),
additionalFee: form.additionalFee,
effectiveFrom: form.effectiveFrom,
effectiveTo: nullable(form.effectiveTo),
remark: nullable(form.remark),
}
}
async function saveVersion() {
const customerId = editorCustomerId.value
if (!customerId) return
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
const payload = buildPayload()
if (!payload) {
ElMessage.error('起运仓或船司主数据已变化,请重新选择')
return
}
saving.value = true
try {
await createPlatformCarrierRateVersion(customerId, payload)
editorOpen.value = false
ElMessage.success('船司 FBA 价格新版本已创建')
await loadRateData(filters.value.customerId)
} catch (error) {
const meta = getApiErrorDetails(error, '价格版本创建失败,请检查有效期和匹配维度后重试')
ElMessage.error(meta.requestId ? `${meta.message}(requestId: ${meta.requestId})` : meta.message)
} finally {
saving.value = false
}
}
async function retireRate(row: PlatformCarrierRateResource) {
try {
await ElMessageBox.confirm(
`停用“${row.rateCode} V${row.rateVersion}”后,新费用试算不会再匹配该版本,历史费用快照不受影响。`,
'停用价格版本',
{ type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消' },
)
retiringIds.value = new Set(retiringIds.value).add(row.id)
await retirePlatformCarrierRate(String(row.tenantId), row.id)
ElMessage.success('价格版本已停用')
await loadRateData(filters.value.customerId)
} catch (error) {
if (error === 'cancel' || error === 'close') return
const meta = getApiErrorDetails(error, '价格版本停用失败,请重试')
ElMessage.error(meta.requestId ? `${meta.message}(requestId: ${meta.requestId})` : meta.message)
} finally {
const next = new Set(retiringIds.value)
next.delete(row.id)
retiringIds.value = next
}
}
function closeEditor() {
if (saving.value) return
editorOpen.value = false
}
onMounted(() => {
void loadCustomers()
void loadRateData()
})
</script>
<template>
<div class="business-page">
<QueryToolbar
v-model="filters"
ariaLabel="船司 FBA 价格筛选"
:fields="queryFields"
@search="applyQuery"
@reset="resetQuery"
/>
<el-alert v-if="customerLoadError" type="warning" :closable="false" show-icon role="alert">
<template #title><div class="api-error-alert-title"><span>{{ customerLoadError.message }}</span><ApiErrorMeta :details="customerLoadError" /></div></template>
<el-button link type="warning" @click="loadCustomers">重新加载企业列表</el-button>
</el-alert>
<LoadErrorState
v-if="loadError"
title="船司 FBA 价格加载失败"
:message="loadErrorMessage"
:loading="loading"
@retry="loadRateData()"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<section class="surface-panel table-panel" aria-live="polite">
<div class="panel-heading">
<div>
<h2>价格版本</h2>
<p>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中</p>
</div>
<el-button
v-if="access.can('firstleg:fba-pricing:rate:edit')"
type="primary"
:disabled="!filters.customerId || masterDataLoading"
@click="openCreate()"
><Plus :size="16" />新增价格版本</el-button>
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="pagedRows"
:columns="columns"
:loading="loading"
:total="filteredRows.length"
:page-sizes="[10, 20, 50]"
row-key="id"
aria-label="船司 FBA 价格版本列表"
empty-text="暂无符合条件的价格版本"
>
<template #actions="{ row }">
<div class="table-actions">
<el-button link type="primary" @click="openDetail(row as PlatformCarrierRateResource)"><Eye :size="15" />详情</el-button>
<el-button
v-if="access.can('firstleg:fba-pricing:rate:edit')"
link
type="primary"
@click="openCreate(row as PlatformCarrierRateResource)"
><Plus :size="15" />新版本</el-button>
<el-button
v-if="access.can('firstleg:fba-pricing:rate:edit') && (row as PlatformCarrierRateResource).status === 'ACTIVE'"
link
type="danger"
:loading="retiringIds.has((row as PlatformCarrierRateResource).id)"
@click="retireRate(row as PlatformCarrierRateResource)"
><Power :size="15" />停用</el-button>
</div>
</template>
<template #customer="{ row }"><strong>{{ customerLabel(String((row as PlatformCarrierRateResource).tenantId)) }}</strong></template>
<template #rate="{ row }">
<div class="primary-cell"><strong>{{ (row as PlatformCarrierRateResource).rateCode }}</strong><span>版本 V{{ (row as PlatformCarrierRateResource).rateVersion }}</span></div>
</template>
<template #carrier="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformCarrierRateResource).carrierName }}</strong><span>ID {{ (row as PlatformCarrierRateResource).carrierId }}</span></div></template>
<template #unitPrice="{ row }"><strong>{{ formatMoney((row as PlatformCarrierRateResource).unitPrice, (row as PlatformCarrierRateResource).currency) }}</strong></template>
<template #additionalFee="{ row }">{{ formatMoney((row as PlatformCarrierRateResource).additionalFee, (row as PlatformCarrierRateResource).currency) }}</template>
<template #period="{ row }"><div class="primary-cell"><strong>{{ formatDateTime((row as PlatformCarrierRateResource).effectiveFrom) }}</strong><span>至 {{ formatDateTime((row as PlatformCarrierRateResource).effectiveTo) }}</span></div></template>
<template #status="{ row }"><StatusBadge :status="statusLabel((row as PlatformCarrierRateResource).status)" /></template>
<template #empty><el-empty description="暂无符合条件的价格版本" /></template>
</DataTable>
</section>
<AppDrawer
v-model="editorOpen"
:title="editorLabel"
size="min(760px, 96vw)"
:confirm-loading="saving"
confirm-text="创建版本"
@confirm="saveVersion"
@cancel="closeEditor"
>
<el-alert type="info" :closable="false" show-icon class="version-note">
价格版本创建后不可原地修改。相同匹配维度的生效时间不得重叠;需要变价时,请创建新版本并停用旧版本。
</el-alert>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="saving">
<section class="form-section">
<h3>版本与运输匹配</h3>
<div class="form-grid two-cols">
<el-form-item label="价格编码" prop="rateCode"><el-input v-model="form.rateCode" maxlength="50" placeholder="例如 FBA-US-WEST" /></el-form-item>
<el-form-item label="起运仓" prop="originWarehouseId">
<el-select v-model="form.originWarehouseId" filterable placeholder="请选择启用的国内收货仓" :loading="masterDataLoading">
<el-option v-for="warehouse in originWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" />
</el-select>
</el-form-item>
<el-form-item label="业务类型" prop="businessType"><el-input v-model="form.businessType" maxlength="32" placeholder="请输入后端使用的业务类型" /></el-form-item>
<el-form-item label="渠道编码" prop="channelCode"><el-input v-model="form.channelCode" maxlength="64" placeholder="请输入渠道编码" /></el-form-item>
<el-form-item label="船司" prop="carrierId">
<el-select v-model="form.carrierId" filterable placeholder="请选择启用的承运商" :loading="masterDataLoading">
<el-option v-for="carrier in activeCarriers" :key="String(carrier.id)" :label="`${carrier.partnerName}(${carrier.partnerCode})`" :value="String(carrier.id)" />
</el-select>
</el-form-item>
<el-form-item label="FBA 目的仓" prop="destinationCode">
<el-select v-model="form.destinationCode" filterable allow-create default-first-option placeholder="选择或输入 FBA 仓编码">
<el-option v-for="warehouse in fbaWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" />
</el-select>
</el-form-item>
</div>
</section>
<section class="form-section">
<h3>计费口径</h3>
<div class="form-grid two-cols">
<el-form-item label="计费单位" prop="chargeUnit"><el-segmented v-model="form.chargeUnit" :options="['KG', 'CBM']" /></el-form-item>
<el-form-item label="单价" prop="unitPrice"><el-input-number v-model="form.unitPrice" :min="0" :precision="4" :step="0.01" controls-position="right" /></el-form-item>
<el-form-item label="币种" prop="currency"><el-input v-model="form.currency" maxlength="3" placeholder="CNY" @input="form.currency = String($event).toUpperCase()" /></el-form-item>
<el-form-item label="固定附加费" prop="additionalFee"><el-input-number v-model="form.additionalFee" :min="0" :precision="2" :step="1" controls-position="right" /></el-form-item>
</div>
</section>
<section class="form-section">
<h3>有效期</h3>
<div class="form-grid two-cols">
<el-form-item label="生效时间" prop="effectiveFrom"><el-date-picker v-model="form.effectiveFrom" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择生效时间" /></el-form-item>
<el-form-item label="结束时间" prop="effectiveTo"><el-date-picker v-model="form.effectiveTo" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="留空表示长期有效" /></el-form-item>
</div>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="记录版本适用说明或变更原因" /></el-form-item>
</section>
</el-form>
</AppDrawer>
<AppDrawer v-model="detailOpen" title="船司 FBA 价格详情" size="min(680px, 96vw)" :loading="detailLoading" :show-footer="false">
<div v-if="detail" class="detail-content">
<header class="detail-heading">
<div><strong>{{ detail.rateCode }}</strong><span>版本 V{{ detail.rateVersion }}</span></div>
<StatusBadge :status="statusLabel(detail.status)" />
</header>
<section class="detail-section"><h3><Warehouse :size="17" />运输匹配</h3><dl class="detail-grid"><div><dt>所属企业</dt><dd>{{ customerLabel(detail.tenantId) }}</dd></div><div><dt>起运仓</dt><dd>{{ detail.originWarehouseName }}</dd></div><div><dt>FBA 目的仓</dt><dd>{{ detail.destinationCode }}</dd></div><div><dt>业务类型</dt><dd>{{ detail.businessType }}</dd></div><div><dt>渠道编码</dt><dd>{{ detail.channelCode }}</dd></div><div><dt>船司</dt><dd>{{ detail.carrierName }}</dd></div><div><dt>船司 ID</dt><dd>{{ detail.carrierId }}</dd></div></dl></section>
<section class="detail-section"><h3><Ship :size="17" />计费口径</h3><dl class="detail-grid"><div><dt>计费单位</dt><dd>{{ detail.chargeUnit }}</dd></div><div><dt>币种</dt><dd>{{ detail.currency }}</dd></div><div><dt>单价</dt><dd>{{ formatMoney(detail.unitPrice, detail.currency) }}</dd></div><div><dt>固定附加费</dt><dd>{{ formatMoney(detail.additionalFee, detail.currency) }}</dd></div></dl></section>
<section class="detail-section"><h3><CalendarClock :size="17" />版本有效期</h3><dl class="detail-grid"><div><dt>生效时间</dt><dd>{{ formatDateTime(detail.effectiveFrom) }}</dd></div><div><dt>结束时间</dt><dd>{{ formatDateTime(detail.effectiveTo) }}</dd></div><div><dt>更新时间</dt><dd>{{ formatDateTime(detail.updatedAt) }}</dd></div><div><dt>记录版本</dt><dd>{{ detail.version ?? '-' }}</dd></div></dl><p v-if="detail.remark" class="detail-remark">{{ detail.remark }}</p></section>
</div>
</AppDrawer>
</div>
</template>
<style scoped>
.version-note { margin-bottom: 18px; }
.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number), .form-grid :deep(.el-segmented) { width: 100%; }
.detail-content { display: grid; gap: 18px; }
.detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
.detail-heading > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.detail-heading strong { font-size: 18px; overflow-wrap: anywhere; }
.detail-heading span { color: var(--cc-muted); font-size: 12px; }
.detail-section { padding: 2px 0 8px; }
.detail-section h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 14px; font-size: 14px; }
.detail-section h3 svg { color: var(--cc-primary); }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 24px; margin: 0; }
.detail-grid div { min-width: 0; }
.detail-grid dt { margin-bottom: 5px; color: var(--cc-muted); font-size: 12px; }
.detail-grid dd { margin: 0; font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
.detail-remark { margin: 16px 0 0; padding: 12px 14px; border-radius: 10px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
@media (max-width: 720px) {
.panel-heading .el-button { width: 100%; min-height: 44px; }
.detail-grid { grid-template-columns: 1fr; gap: 12px; }
}
</style>
@@ -0,0 +1,86 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync('src/modules/fba-pricing/customer-rules/CustomerRulesView.vue', 'utf8')
const queryToolbarSource = readFileSync('src/shared/components/QueryToolbar.vue', 'utf8')
describe('CustomerRulesView', () => {
it('keeps the operation column first and exposes version-only actions', () => {
const table = source.indexOf('const columns')
const operation = source.indexOf("label: '操作'", table)
const ruleCode = source.indexOf("label: '规则版本'", table)
expect(operation).toBeGreaterThan(0)
expect(operation).toBeLessThan(ruleCode)
expect(source).toContain('新建版本')
expect(source).toContain('停用规则')
expect(source).not.toContain('编辑规则')
})
it('covers customer scope, permission, unsaved work, error and responsive states', () => {
expect(source).not.toContain('PlatformTenantSelector')
expect(source).toContain("key: 'customerId'")
expect(source).toContain("'aria-label': '所属企业'")
expect(source).toContain('listPlatformTenants')
expect(source).toContain("firstleg:fba-pricing:rule:edit")
expect(source).toContain('useUnsavedWorkRegistration')
expect(source).toContain('LoadErrorState')
expect(source).toContain('detailError')
expect(source).toContain('@retry="retryDetail"')
expect(source).not.toContain('请选择客户')
expect(source).not.toContain('请选择所属企业')
expect(source).not.toContain('当前接口要求选择客户')
expect(source).not.toContain('租户')
expect(source).toContain('@media (max-width: 480px)')
})
it('uses compact semantic filter widths and keeps the customer filter empty by default', () => {
expect(source).toContain("customerId: '', ruleCode: '', customerName: '', chargeMode: '', status: ''")
expect(source).toContain("width: 280,\n placeholder: customerLoadError.value ? '客户列表加载失败' : '全部客户'")
expect(source).toContain("key: 'ruleCode', type: 'input', width: 220")
expect(source).toContain("key: 'customerName', type: 'input', width: 260")
expect(source).toContain("key: 'chargeMode', type: 'select', width: 180")
expect(source).toContain("key: 'status', type: 'select', width: 160")
expect(source).not.toContain('customers.value[0]')
})
it('resets to all customers, uses full-width mobile fields and allows an empty customer filter', () => {
expect(queryToolbarSource).toContain("for (const field of props.fields) next[field.key] = resetValue(field)")
expect(queryToolbarSource).toContain("if (field.type === 'select') return '全部'")
expect(queryToolbarSource).toContain('@media (max-width: 720px)')
expect(queryToolbarSource).toContain('{ width: 100%; }')
expect(source).not.toContain('if (!selectedCustomerId.value)')
expect(source).toContain('...(selectedCustomerId.value ? { tenantId: selectedCustomerId.value } : {})')
expect(source).toContain('void loadRules()')
expect(source).toContain("const tableEmptyText = '暂无符合筛选条件的客户计费规则'")
})
it('uses each record scope for single-rule actions', () => {
expect(source).toContain('getCustomerPricingRule(row.tenantId, row.id)')
expect(source).toContain('editorScopeCustomerId.value = source?.tenantId || selectedCustomerId.value')
expect(source).toContain('disableCustomerPricingRule(row.tenantId, row.id)')
})
it('renders the complete pricing rule contract', () => {
for (const label of ['适用客户', '业务类型', '计费方式', '税务模式', '换算系数', '舍入规则', '币种', '有效期']) {
expect(source).toContain(label)
}
expect(source).toContain('QueryToolbar')
expect(source).toContain('DataTable')
expect(source).toContain('AppDrawer')
})
it('matches the backend status and field constraints', () => {
expect(source).toContain("{ label: '已停用', value: 'INACTIVE' }")
expect(source).not.toContain("value: 'DISABLED'")
expect(source).toContain('maxlength="50"')
})
it('uses the mature admin list skeleton and grouped detail sections', () => {
expect(source).toContain('class="business-page customer-rules-page"')
expect(source).toContain('class="surface-panel table-panel"')
expect(source).toContain('class="panel-heading"')
for (const heading of ['规则归属', '计费口径', '版本有效期']) {
expect(source).toContain(`<h3>${heading}</h3>`)
}
})
})
@@ -0,0 +1,496 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { CircleSlash2, Eye, Plus } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { listPlatformTenants, type PlatformTenantResource as PlatformCustomerContract } from '@/modules/iam'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import {
createCustomerPricingRuleVersion,
disableCustomerPricingRule,
getCustomerPricingRule,
listCustomerPricingRules,
} from './api'
import type { CustomerPricingRule, CustomerRuleFormModel } from './types'
const access = useAccessPreviewStore()
const canEdit = computed(() => access.can('firstleg:fba-pricing:rule:edit'))
const customers = ref<Array<{ id: string; name: string; code: string }>>([])
const customerLoadError = ref<ApiErrorDetails | null>(null)
const rules = ref<CustomerPricingRule[]>([])
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const selectedRule = ref<CustomerPricingRule | null>(null)
const editorVisible = ref(false)
const editorSaving = ref(false)
const editorForm = ref<FormInstance>()
const editorDirty = ref(false)
const editorScopeCustomerId = ref('')
const page = ref(1)
const pageSize = ref(20)
type RuleFilters = QueryModel & {
customerId: string
ruleCode: string
customerName: string
chargeMode: '' | 'KG' | 'CBM'
status: string
}
const filters = ref<RuleFilters>({ customerId: '', ruleCode: '', customerName: '', chargeMode: '', status: '' })
const appliedFilters = ref<RuleFilters>({ ...filters.value })
const selectedCustomerId = computed(() => appliedFilters.value.customerId)
const queryFields = computed<QueryField[]>(() => [
{
key: 'customerId',
type: 'select',
width: 280,
placeholder: customerLoadError.value ? '客户列表加载失败' : '全部客户',
props: { 'aria-label': '所属企业', filterable: true, disabled: Boolean(customerLoadError.value) },
options: customers.value.map((customer) => ({
value: customer.id,
label: `${customer.name}(${customer.code || customer.id})`,
})),
},
{ key: 'ruleCode', type: 'input', width: 220, placeholder: '规则编码' },
{ key: 'customerName', type: 'input', width: 260, placeholder: '客户名称' },
{
key: 'chargeMode', type: 'select', width: 180, placeholder: '全部计费方式', options: [
{ label: '按 KG', value: 'KG' },
{ label: '按 CBM', value: 'CBM' },
],
},
{
key: 'status', type: 'select', width: 160, placeholder: '全部状态', options: [
{ label: '生效中', value: 'ACTIVE' },
{ label: '已停用', value: 'INACTIVE' },
],
},
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ key: 'rule', label: '规则版本', minWidth: 170, slotName: 'rule', showOverflowTooltip: true },
{ key: 'customer', label: '适用客户', minWidth: 190, slotName: 'customer', showOverflowTooltip: true },
{ key: 'businessType', prop: 'businessType', label: '业务类型', minWidth: 110, showOverflowTooltip: true },
{ key: 'chargeMode', label: '计费方式', width: 105, slotName: 'chargeMode' },
{ key: 'taxMode', label: '税务模式', width: 100, slotName: 'taxMode' },
{ key: 'conversionFactor', prop: 'conversionFactor', label: '换算系数', width: 105 },
{ key: 'rounding', label: '舍入规则', minWidth: 155, slotName: 'rounding' },
{ key: 'currency', prop: 'currency', label: '币种', width: 80 },
{ key: 'period', label: '有效期', minWidth: 225, slotName: 'period' },
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
]
const filteredRules = computed(() => rules.value.filter((rule) => {
const customerName = appliedFilters.value.customerName.trim().toLowerCase()
return (!customerName || rule.customerName.toLowerCase().includes(customerName))
&& (!appliedFilters.value.chargeMode || rule.chargeMode === appliedFilters.value.chargeMode)
&& (!appliedFilters.value.status || rule.status === appliedFilters.value.status)
}))
const pagedRules = computed(() => filteredRules.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const tableEmptyText = '暂无符合筛选条件的客户计费规则'
const emptyForm = (): CustomerRuleFormModel => ({
ruleCode: '',
customerId: '',
customerName: '',
businessType: 'FBA',
chargeMode: 'KG',
taxMode: 'TAX_INCLUDED',
conversionFactor: 167,
roundingScale: 3,
roundingMode: 'CEILING',
currency: 'CNY',
effectiveFrom: '',
effectiveTo: null,
remark: null,
})
const form = reactive<CustomerRuleFormModel>(emptyForm())
const formRules: FormRules<CustomerRuleFormModel> = {
ruleCode: [{ required: true, message: '请输入规则编码', trigger: 'blur' }],
customerId: [{ required: true, message: '请输入客户 ID', trigger: 'blur' }],
customerName: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
businessType: [{ required: true, message: '请输入业务类型', trigger: 'blur' }],
chargeMode: [{ required: true, message: '请选择计费方式', trigger: 'change' }],
taxMode: [{ required: true, message: '请选择税务模式', trigger: 'change' }],
conversionFactor: [{ required: true, message: '请输入大于 0 的换算系数', trigger: 'blur', type: 'number', min: 0.000001 }],
roundingScale: [{ required: true, message: '请选择保留位数', trigger: 'change', type: 'number', min: 0, max: 4 }],
roundingMode: [{ required: true, message: '请选择舍入方式', trigger: 'change' }],
currency: [{ required: true, message: '请选择币种', trigger: 'change' }],
effectiveFrom: [{ required: true, message: '请选择生效时间', trigger: 'change' }],
}
useUnsavedWorkRegistration({
key: 'customer-pricing-rule-version',
label: computed(() => '客户计费规则版本'),
isOpen: computed(() => editorVisible.value),
model: form,
requiredPermission: computed(() => 'firstleg:fba-pricing:rule:edit'),
})
watch(form, () => {
if (editorVisible.value) editorDirty.value = true
}, { deep: true })
async function loadCustomers() {
customerLoadError.value = null
try {
const records: PlatformCustomerContract[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200 })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
customers.value = records.map((customer) => ({
id: String(customer.id),
name: customer.tenantName,
code: customer.tenantCode,
}))
} catch (cause) {
customers.value = []
customerLoadError.value = getApiErrorDetails(cause, '客户列表加载失败')
}
}
async function loadRules() {
loading.value = true
error.value = null
try {
rules.value = await listCustomerPricingRules({
...(selectedCustomerId.value ? { tenantId: selectedCustomerId.value } : {}),
ruleCode: appliedFilters.value.ruleCode.trim(),
})
} catch (cause) {
error.value = getApiErrorDetails(cause, '客户计费规则加载失败')
} finally {
loading.value = false
}
}
function applyQuery(model: QueryModel) {
const nextCustomerId = String(model.customerId || '')
if (nextCustomerId !== selectedCustomerId.value) {
rules.value = []
selectedRule.value = null
detailVisible.value = false
detailError.value = null
editorVisible.value = false
editorDirty.value = false
error.value = null
}
appliedFilters.value = {
customerId: nextCustomerId,
ruleCode: String(model.ruleCode || ''),
customerName: String(model.customerName || ''),
chargeMode: (model.chargeMode || '') as RuleFilters['chargeMode'],
status: String(model.status || ''),
}
page.value = 1
void loadRules()
}
async function openDetail(row: CustomerPricingRule) {
detailVisible.value = true
detailLoading.value = true
detailError.value = null
selectedRule.value = row
try {
selectedRule.value = await getCustomerPricingRule(row.tenantId, row.id)
} catch (cause) {
detailError.value = getApiErrorDetails(cause, '规则详情加载失败')
} finally {
detailLoading.value = false
}
}
function retryDetail() {
if (selectedRule.value) void openDetail(selectedRule.value)
}
function asRule(row: unknown): CustomerPricingRule {
return row as CustomerPricingRule
}
function resetForm(source?: CustomerPricingRule) {
Object.assign(form, emptyForm(), source ? {
ruleCode: source.ruleCode,
customerId: source.customerId,
customerName: source.customerName,
businessType: source.businessType,
chargeMode: source.chargeMode,
taxMode: source.taxMode,
conversionFactor: source.conversionFactor,
roundingScale: source.roundingScale,
roundingMode: source.roundingMode,
currency: source.currency,
effectiveTo: source.effectiveTo,
remark: source.remark,
} : { customerId: selectedCustomerId.value })
}
async function openEditor(source?: CustomerPricingRule) {
editorScopeCustomerId.value = source?.tenantId || selectedCustomerId.value
resetForm(source)
editorDirty.value = false
editorVisible.value = true
await nextTick()
editorForm.value?.clearValidate()
}
async function closeEditor() {
if (editorDirty.value) {
try {
await ElMessageBox.confirm('尚有未保存的规则版本,确定放弃吗?', '放弃编辑', {
confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning',
})
} catch {
editorVisible.value = true
return
}
}
editorVisible.value = false
editorDirty.value = false
}
async function beforeEditorClose(done: () => void) {
if (editorSaving.value) return
if (!editorDirty.value) {
done()
return
}
try {
await ElMessageBox.confirm('尚有未保存的规则版本,确定放弃吗?', '放弃编辑', {
confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning',
})
editorDirty.value = false
done()
} catch {
editorVisible.value = true
}
}
async function saveVersion() {
if (!await editorForm.value?.validate()) return
editorSaving.value = true
try {
await createCustomerPricingRuleVersion(editorScopeCustomerId.value, {
...form,
ruleCode: form.ruleCode.trim(),
customerId: String(form.customerId),
customerName: form.customerName.trim(),
businessType: form.businessType.trim(),
effectiveTo: form.effectiveTo || null,
remark: form.remark?.trim() || null,
})
editorDirty.value = false
editorVisible.value = false
ElMessage.success('客户计费规则新版本已创建')
await loadRules()
} catch (cause) {
ElMessage.error(getApiErrorDetails(cause, '规则版本创建失败').message)
} finally {
editorSaving.value = false
}
}
async function disableRule(row: CustomerPricingRule) {
try {
await ElMessageBox.confirm(`停用规则“${row.ruleCode}”的当前版本?历史费用不会改变。`, '停用规则', {
confirmButtonText: '确认停用', cancelButtonText: '取消', type: 'warning',
})
await disableCustomerPricingRule(row.tenantId, row.id)
ElMessage.success('规则已停用')
await loadRules()
} catch (cause) {
if (cause === 'cancel' || cause === 'close') return
ElMessage.error(getApiErrorDetails(cause, '规则停用失败').message)
}
}
function formatDate(value: string | null) {
if (!value) return '长期有效'
return value.replace('T', ' ').slice(0, 16)
}
function modeLabel(mode: string) {
return mode === 'KG' ? '按 KG' : mode === 'CBM' ? '按 CBM' : mode
}
function taxLabel(mode: string) {
return mode === 'TAX_INCLUDED' ? '包税' : mode === 'TAX_EXCLUDED' ? '不包税' : mode
}
onMounted(() => {
access.initializeAfterLogin()
void loadCustomers()
void loadRules()
})
</script>
<template>
<div class="business-page customer-rules-page">
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户计费规则筛选" :fields="queryFields" @search="applyQuery" />
<LoadErrorState v-if="customerLoadError" title="客户列表加载失败" :message="customerLoadError.message" compact @retry="loadCustomers" />
<LoadErrorState v-if="error" title="客户计费规则加载失败" :message="error.message" @retry="loadRules" />
<section v-else class="surface-panel table-panel" aria-live="polite" :aria-busy="loading">
<div class="panel-heading">
<div>
<h2>客户计费规则</h2>
<p>按企业维护 FBA 的 KG / CBM 计费版本 · 共 {{ filteredRules.length }} 个规则版本</p>
</div>
<el-button v-if="canEdit" type="primary" :disabled="loading || !selectedCustomerId" @click="openEditor()">
<Plus :size="16" aria-hidden="true" />新建版本
</el-button>
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="pagedRules"
:columns="columns"
:loading="loading"
:total="filteredRules.length"
:page-sizes="[10, 20, 50]"
row-key="id"
:empty-text="tableEmptyText"
>
<template #actions="{ row }">
<div class="row-actions">
<el-button link type="primary" @click="openDetail(asRule(row))"><Eye :size="15" aria-hidden="true" />详情</el-button>
<el-button v-if="canEdit" link type="primary" @click="openEditor(asRule(row))"><Plus :size="15" aria-hidden="true" />新版本</el-button>
<el-button v-if="canEdit && row.status === 'ACTIVE'" link type="danger" @click="disableRule(asRule(row))"><CircleSlash2 :size="15" aria-hidden="true" />停用</el-button>
</div>
</template>
<template #rule="{ row }"><div class="primary-cell"><strong>{{ row.ruleCode }}</strong><small>版本 V{{ row.ruleVersion ?? '—' }}</small></div></template>
<template #customer="{ row }"><strong>{{ row.customerName }}</strong><small class="subline">ID {{ row.customerId }}</small></template>
<template #chargeMode="{ row }"><el-tag effect="plain">{{ modeLabel(row.chargeMode) }}</el-tag></template>
<template #taxMode="{ row }">{{ taxLabel(row.taxMode) }}</template>
<template #rounding="{ row }">保留 {{ row.roundingScale }} 位 · {{ row.roundingMode }}</template>
<template #period="{ row }"><span>{{ formatDate(row.effectiveFrom) }}</span><small class="subline">至 {{ formatDate(row.effectiveTo) }}</small></template>
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ row.status === 'ACTIVE' ? '生效中' : '已停用' }}</el-tag></template>
<template #pagination-left>共 {{ filteredRules.length }} 个规则版本</template>
</DataTable>
</section>
<AppDrawer v-model="detailVisible" title="客户计费规则详情" size="min(600px, 94vw)" :loading="detailLoading" :show-footer="false">
<LoadErrorState v-if="detailError" title="规则详情加载失败" :message="detailError.message" compact @retry="retryDetail" />
<div v-else-if="selectedRule" class="rule-detail">
<section>
<h3>规则归属</h3>
<dl class="detail-grid">
<div><dt>规则编码</dt><dd>{{ selectedRule.ruleCode }}</dd></div>
<div><dt>规则版本</dt><dd>V{{ selectedRule.ruleVersion ?? '—' }}</dd></div>
<div><dt>适用客户</dt><dd>{{ selectedRule.customerName }}</dd></div>
<div><dt>客户 ID</dt><dd>{{ selectedRule.customerId }}</dd></div>
<div><dt>业务类型</dt><dd>{{ selectedRule.businessType }}</dd></div>
<div><dt>状态</dt><dd><el-tag :type="selectedRule.status === 'ACTIVE' ? 'success' : 'info'">{{ selectedRule.status === 'ACTIVE' ? '生效中' : '已停用' }}</el-tag></dd></div>
</dl>
</section>
<section>
<h3>计费口径</h3>
<dl class="detail-grid">
<div><dt>计费方式</dt><dd>{{ modeLabel(selectedRule.chargeMode) }}</dd></div>
<div><dt>税务模式</dt><dd>{{ taxLabel(selectedRule.taxMode) }}</dd></div>
<div><dt>换算系数</dt><dd>{{ selectedRule.conversionFactor }}</dd></div>
<div><dt>舍入规则</dt><dd>保留 {{ selectedRule.roundingScale }} 位,{{ selectedRule.roundingMode }}</dd></div>
<div><dt>币种</dt><dd>{{ selectedRule.currency }}</dd></div>
</dl>
</section>
<section>
<h3>版本有效期</h3>
<dl class="detail-grid">
<div><dt>生效时间</dt><dd>{{ formatDate(selectedRule.effectiveFrom) }}</dd></div>
<div><dt>失效时间</dt><dd>{{ formatDate(selectedRule.effectiveTo) }}</dd></div>
<div class="detail-wide"><dt>备注</dt><dd>{{ selectedRule.remark || '—' }}</dd></div>
</dl>
</section>
</div>
</AppDrawer>
<AppDrawer
v-model="editorVisible"
title="创建客户计费规则版本"
size="min(760px, 94vw)"
:confirm-loading="editorSaving"
confirm-text="创建版本"
:before-close="beforeEditorClose"
@confirm="saveVersion"
@cancel="closeEditor"
>
<el-form ref="editorForm" :model="form" :rules="formRules" label-position="top" class="rule-form">
<div class="form-section">
<h2>规则归属</h2>
<div class="form-grid">
<el-form-item label="规则编码" prop="ruleCode"><el-input v-model="form.ruleCode" maxlength="50" placeholder="例如 FBA-TAX-KG" /></el-form-item>
<el-form-item label="客户 ID" prop="customerId"><el-input v-model="form.customerId" /></el-form-item>
<el-form-item label="客户名称" prop="customerName"><el-input v-model="form.customerName" maxlength="128" /></el-form-item>
<el-form-item label="业务类型" prop="businessType"><el-input v-model="form.businessType" maxlength="32" /></el-form-item>
</div>
</div>
<div class="form-section">
<h2>计费口径</h2>
<div class="form-grid">
<el-form-item label="计费方式" prop="chargeMode"><el-segmented v-model="form.chargeMode" :options="[{ label: '按 KG', value: 'KG' }, { label: '按 CBM', value: 'CBM' }]" /></el-form-item>
<el-form-item label="税务模式" prop="taxMode"><el-select v-model="form.taxMode"><el-option label="包税" value="TAX_INCLUDED" /><el-option label="不包税" value="TAX_EXCLUDED" /></el-select></el-form-item>
<el-form-item label="换算系数" prop="conversionFactor"><el-input-number v-model="form.conversionFactor" :min="0.000001" :precision="6" :controls="false" /></el-form-item>
<el-form-item label="保留小数位" prop="roundingScale"><el-select v-model="form.roundingScale"><el-option v-for="scale in [0, 1, 2, 3, 4]" :key="scale" :label="`${scale} 位`" :value="scale" /></el-select></el-form-item>
<el-form-item label="舍入方式" prop="roundingMode"><el-select v-model="form.roundingMode"><el-option label="向上取整 CEILING" value="CEILING" /><el-option label="向上舍入 UP" value="UP" /><el-option label="向下舍入 DOWN" value="DOWN" /><el-option label="向下取整 FLOOR" value="FLOOR" /><el-option label="四舍五入 HALF_UP" value="HALF_UP" /><el-option label="五舍六入 HALF_DOWN" value="HALF_DOWN" /><el-option label="银行家舍入 HALF_EVEN" value="HALF_EVEN" /></el-select></el-form-item>
<el-form-item label="币种" prop="currency"><el-select v-model="form.currency" filterable allow-create default-first-option><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /></el-select></el-form-item>
</div>
</div>
<div class="form-section">
<h2>生效范围</h2>
<div class="form-grid">
<el-form-item label="生效时间" prop="effectiveFrom"><el-date-picker v-model="form.effectiveFrom" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择生效时间" /></el-form-item>
<el-form-item label="失效时间"><el-date-picker v-model="form.effectiveTo" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="不填表示长期有效" /></el-form-item>
<el-form-item label="备注" class="wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
</div>
</div>
</el-form>
</AppDrawer>
</div>
</template>
<style scoped>
.customer-rules-page { min-width: 0; }
.row-actions .el-button { display: inline-flex; align-items: center; gap: 6px; }
.row-actions { display: flex; align-items: center; gap: 3px; white-space: nowrap; }
.primary-cell { display: flex; flex-direction: column; gap: 3px; }
.primary-cell small, .subline { display: block; margin-top: 3px; color: var(--cc-muted); font-size: 11px; line-height: 1.45; }
.rule-detail { display: grid; gap: 24px; }
.rule-detail section + section { padding-top: 22px; border-top: 1px solid var(--cc-border); }
.rule-detail h3 { margin: 0 0 14px; color: var(--cc-text); font-size: 15px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 24px; margin: 0; }
.detail-grid > div { min-width: 0; }
.detail-grid dt { margin-bottom: 5px; color: var(--cc-muted); font-size: 12px; }
.detail-grid dd { margin: 0; color: var(--cc-text); line-height: 1.55; overflow-wrap: anywhere; }
.detail-wide { grid-column: 1 / -1; }
.rule-form { display: grid; gap: 24px; }
.form-section { display: grid; gap: 14px; }
.form-section + .form-section { padding-top: 22px; border-top: 1px solid var(--cc-border); }
.form-section h2 { margin: 0; color: var(--cc-text); font-size: 16px; line-height: 1.4; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 20px; }
.wide { grid-column: 1 / -1; }
.form-grid :deep(.el-select), .form-grid :deep(.el-input-number), .form-grid :deep(.el-date-editor) { width: 100%; }
@media (max-width: 760px) {
.form-grid { grid-template-columns: 1fr; }
.wide { grid-column: auto; }
}
@media (max-width: 480px) {
.detail-grid { grid-template-columns: 1fr; }
.detail-wide { grid-column: auto; }
}
</style>
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const http = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
}))
vi.mock('@/shared/api/http', () => ({
http,
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
import {
createCustomerPricingRuleVersion,
disableCustomerPricingRule,
getCustomerPricingRule,
listCustomerPricingRules,
} from './api'
const rawRule = {
id: 9007199254740993n.toString(),
tenantId: 10001,
ruleCode: 'FBA-TAX-KG',
customerId: 20002,
customerName: '测试企业',
businessType: 'FBA',
chargeMode: 'KG',
taxMode: 'TAX_INCLUDED',
conversionFactor: 167,
roundingScale: 3,
roundingMode: 'CEILING',
currency: 'CNY',
effectiveFrom: '2026-08-04T00:00:00',
effectiveTo: null,
ruleVersion: 1,
status: 'ACTIVE',
remark: null,
createdAt: null,
updatedAt: null,
}
describe('customer pricing rules api', () => {
beforeEach(() => vi.clearAllMocks())
it('omits an empty customer scope and rule code for the all-customer list', async () => {
http.get.mockResolvedValue({ data: [rawRule] })
await listCustomerPricingRules({ tenantId: ' ', ruleCode: ' ' })
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules', { params: {} })
})
it('keeps every long id as a string and sends the real list query', async () => {
http.get.mockResolvedValue({ data: [rawRule] })
const result = await listCustomerPricingRules({ tenantId: '10001', ruleCode: ' FBA ' })
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules', {
params: { tenantId: '10001', ruleCode: 'FBA' },
})
expect(result[0]).toMatchObject({ id: '9007199254740993', tenantId: '10001', customerId: '20002' })
})
it('uses detail, version creation and disable endpoints from the controller', async () => {
http.get.mockResolvedValue({ data: rawRule })
http.post.mockResolvedValue({ data: rawRule })
http.delete.mockResolvedValue({ data: null })
await getCustomerPricingRule('10001', '9')
await createCustomerPricingRuleVersion('10001', {
ruleCode: 'FBA-TAX-KG', customerId: '20002', customerName: '测试企业', businessType: 'FBA',
chargeMode: 'KG', taxMode: 'TAX_INCLUDED', conversionFactor: 167, roundingScale: 3,
roundingMode: 'CEILING', currency: 'CNY', effectiveFrom: '2026-08-04T00:00:00',
})
await disableCustomerPricingRule('10001', '9')
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules/9', { params: { tenantId: '10001' } })
expect(http.post).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules/versions', expect.any(Object), { params: { tenantId: '10001' } })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules/9', { params: { tenantId: '10001' } })
})
})
@@ -0,0 +1,59 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
CreateCustomerRuleVersionRequest,
CustomerPricingRule,
CustomerRuleQuery,
} from './types'
type RawRule = Omit<CustomerPricingRule, 'id' | 'tenantId' | 'customerId'> & {
id: string | number
tenantId: string | number
customerId: string | number
}
function normalizeRule(rule: RawRule): CustomerPricingRule {
return {
...rule,
id: String(rule.id),
tenantId: String(rule.tenantId),
customerId: String(rule.customerId),
}
}
export async function listCustomerPricingRules(query: CustomerRuleQuery): Promise<CustomerPricingRule[]> {
const tenantId = query.tenantId?.trim()
const ruleCode = query.ruleCode?.trim()
const response = await http.get<never, ApiEnvelope<RawRule[]>>('/platform/v1/fba-pricing/customer-rules', {
params: {
...(tenantId ? { tenantId } : {}),
...(ruleCode ? { ruleCode } : {}),
},
})
return unwrapApiResponse(response).map(normalizeRule)
}
export async function getCustomerPricingRule(tenantId: string, id: string): Promise<CustomerPricingRule> {
const response = await http.get<never, ApiEnvelope<RawRule>>(`/platform/v1/fba-pricing/customer-rules/${id}`, {
params: { tenantId },
})
return normalizeRule(unwrapApiResponse(response))
}
export async function createCustomerPricingRuleVersion(
tenantId: string,
request: CreateCustomerRuleVersionRequest,
): Promise<CustomerPricingRule> {
const response = await http.post<never, ApiEnvelope<RawRule>>(
'/platform/v1/fba-pricing/customer-rules/versions',
request,
{ params: { tenantId } },
)
return normalizeRule(unwrapApiResponse(response))
}
export async function disableCustomerPricingRule(tenantId: string, id: string): Promise<void> {
const response = await http.delete<never, ApiEnvelope<unknown>>(`/platform/v1/fba-pricing/customer-rules/${id}`, {
params: { tenantId },
})
unwrapApiResponse(response)
}
@@ -0,0 +1,49 @@
export type CustomerRuleChargeMode = 'KG' | 'CBM'
export type CustomerRuleStatus = 'ACTIVE' | 'INACTIVE' | string
export interface CustomerPricingRule {
id: string
tenantId: string
ruleCode: string
customerId: string
customerName: string
businessType: string
chargeMode: CustomerRuleChargeMode
taxMode: string
conversionFactor: number
roundingScale: number
roundingMode: string
currency: string
effectiveFrom: string
effectiveTo: string | null
ruleVersion: number | null
status: CustomerRuleStatus
remark: string | null
createdAt: string | null
updatedAt: string | null
}
export interface CustomerRuleQuery {
tenantId?: string
ruleCode?: string
}
export interface CreateCustomerRuleVersionRequest {
ruleCode: string
customerId: string
customerName: string
businessType: string
chargeMode: CustomerRuleChargeMode
taxMode: string
conversionFactor: number
roundingScale: number
roundingMode: string
currency: string
effectiveFrom: string
effectiveTo?: string | null
remark?: string | null
}
export interface CustomerRuleFormModel extends CreateCustomerRuleVersionRequest {
customerId: string
}
+24
View File
@@ -0,0 +1,24 @@
import type { RouteRecordRaw } from 'vue-router'
export const fbaPricingRoutes = [
{
path: 'first-leg-pricing/customer-rules',
name: 'fba-customer-rules',
component: () => import('./customer-rules/CustomerRulesView.vue'),
meta: {
title: '客户计费规则',
description: '维护 FBA 客户计费规则和生效版本',
permission: 'firstleg:fba-pricing:rule:view',
},
},
{
path: 'first-leg-pricing/carrier-rates',
name: 'fba-carrier-rates',
component: () => import('./carrier-rates/pages/CarrierRatesView.vue'),
meta: {
title: '船司 FBA 价格',
description: '维护起运仓、船司和 FBA 目的仓价格版本',
permission: 'firstleg:fba-pricing:rate:view',
},
},
] satisfies RouteRecordRaw[]
@@ -10,7 +10,7 @@ describe('PlatformFirstLegOrdersPanel review queue', () => {
expect(source).toContain("tenantId: filters.value.tenantId || undefined")
expect(source).toContain('listPlatformTenants')
expect(source).toContain("key: 'tenantId'")
expect(source).toContain('tenantName((row as PlatformFirstLegOrderResource).tenantId)')
expect(source).toContain('customerDisplay(row as PlatformFirstLegOrderResource).name')
expect(source).toContain('result.pages')
})
@@ -34,6 +34,16 @@ describe('PlatformFirstLegOrdersPanel review queue', () => {
expect(source).toContain("minWidth: 224, fixed: 'left'")
})
it('prioritizes order status and removes noisy customer and time metadata', () => {
const statusColumn = source.indexOf("{ key: 'status', label: '状态'")
const orderColumn = source.indexOf("{ key: 'orderInfo', label: '订单信息'")
expect(statusColumn).toBeGreaterThan(0)
expect(statusColumn).toBeLessThan(orderColumn)
expect(source).toContain("replace('T', ' ').slice(0, 16)")
expect(source).toContain('客户编码 ${code}')
expect(source).not.toContain('customerName }} · {{ (row as PlatformFirstLegOrderResource).tenantId')
})
it('passes the order tenant through detail and lifecycle actions', () => {
expect(source).toContain('tenantId: String(order.tenantId)')
expect(source).toContain('getPlatformFirstLegOrder(tenantId, orderId)')
@@ -65,14 +65,15 @@ const queryFields = computed<QueryField[]>(() => [
{
key: 'tenantId',
type: 'select',
width: 160,
placeholder: tenantLoadError.value ? '企业列表加载失败' : '全部企业',
props: { 'aria-label': '企业客户', filterable: true, disabled: Boolean(tenantLoadError.value) },
options: tenants.value.map((tenant) => ({ value: String(tenant.id), label: `${tenant.tenantName}(${tenant.tenantCode || tenant.id})` })),
},
{ key: 'keyword', type: 'input', placeholder: '订单号或发货客户名称', props: { 'aria-label': '订单关键词' } },
{
key: 'status',
type: 'select',
width: 120,
placeholder: '全部状态',
props: { 'aria-label': '订单状态' },
options: [
@@ -86,18 +87,19 @@ const queryFields = computed<QueryField[]>(() => [
{ value: 'REJECTED', label: '已驳回(旧契约)' },
],
},
{ key: 'keyword', type: 'input', placeholder: '订单号或发货客户名称', props: { 'aria-label': '订单关键词' } },
{ key: 'dateRange', type: 'daterange', props: { 'aria-label': '订单创建日期' } },
])
const orderColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', minWidth: 224, fixed: 'left', slotName: 'actions' },
{ key: 'status', label: '状态', width: 108, slotName: 'status' },
{ key: 'orderInfo', label: '订单信息', minWidth: 210, slotName: 'orderInfo' },
{ key: 'customer', label: '客户', minWidth: 190, showOverflowTooltip: true, slotName: 'customer' },
{ key: 'transport', label: '运输方案', minWidth: 180, slotName: 'transport' },
{ key: 'destination', label: '目的地', minWidth: 150, slotName: 'destination' },
{ key: 'receivingWarehouse', label: '平台收货仓', minWidth: 180, slotName: 'receivingWarehouse' },
{ key: 'express', label: '履约快递', minWidth: 160, slotName: 'express' },
{ key: 'status', label: '状态', width: 120, slotName: 'status' },
]
const expressRules: FormRules = {
@@ -145,11 +147,23 @@ function transportLabel(order: PlatformFirstLegOrderResource) {
}
function formattedDate(value: string | null | undefined) {
return value ? value.replace('T', ' ') : '-'
return value ? value.replace('T', ' ').slice(0, 16) : '-'
}
function tenantFor(tenantId: number | string) {
return tenants.value.find((tenant) => String(tenant.id) === String(tenantId))
}
function tenantName(tenantId: number | string) {
return tenants.value.find((tenant) => String(tenant.id) === String(tenantId))?.tenantName || `企业 ${tenantId}`
return tenantFor(tenantId)?.tenantName || `企业 ${tenantId}`
}
function customerDisplay(order: PlatformFirstLegOrderResource) {
const tenant = tenantFor(order.tenantId)
const name = tenant?.legalName?.trim() || tenant?.tenantName?.trim() || order.customerName?.trim() || '未命名企业'
const code = tenant?.tenantCode?.trim()
const secondary = code ? `客户编码 ${code}` : order.customerName?.trim()
return { name, secondary: secondary && secondary !== name ? secondary : '' }
}
async function loadTenants() {
@@ -535,13 +549,13 @@ onMounted(() => void loadTenants())
</el-dropdown>
</div>
</template>
<template #status="{ row }"><StatusBadge :status="statusLabel((row as PlatformFirstLegOrderResource).status)" /></template>
<template #orderInfo="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).orderNo || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).referenceId || '无客户参考号' }} · {{ formattedDate((row as PlatformFirstLegOrderResource).createdAt) }}</span></div></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ tenantName((row as PlatformFirstLegOrderResource).tenantId) }}</strong><span>{{ (row as PlatformFirstLegOrderResource).customerName }} · {{ (row as PlatformFirstLegOrderResource).tenantId }}</span></div></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerDisplay(row as PlatformFirstLegOrderResource).name }}</strong><span v-if="customerDisplay(row as PlatformFirstLegOrderResource).secondary">{{ customerDisplay(row as PlatformFirstLegOrderResource).secondary }}</span></div></template>
<template #transport="{ row }"><div class="primary-cell"><strong>{{ transportLabel(row as PlatformFirstLegOrderResource) }}</strong><span>{{ (row as PlatformFirstLegOrderResource).channelName || '未填写渠道' }}</span></div></template>
<template #destination="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '-' }}</span></div></template>
<template #receivingWarehouse="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseAddress || '-' }}</span></div></template>
<template #express="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).expressCompany || '未维护' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).expressTrackingNo || '—' }}</span></div></template>
<template #status="{ row }"><StatusBadge :status="statusLabel((row as PlatformFirstLegOrderResource).status)" /></template>
<template #empty><el-empty description="当前没有符合条件的头程订单" /></template>
</DataTable>
</section>
+1 -1
View File
@@ -53,7 +53,7 @@ export interface CreatePlatformTenantRequest {
tenantName: string
legalName: string
legalRepresentative: string
unifiedSocialCreditCode: string
unifiedSocialCreditCode?: string
contactName: string
contactPhone: string
contactEmail: string
@@ -0,0 +1,61 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/components/InboundWorkflowWorkspace.vue'), 'utf8')
describe('InboundWorkflowWorkspace', () => {
it('assembles every phase-two inbound domain behind one server-backed workspace', () => {
expect(source).toContain('InboundSummaryPanel')
expect(source).toContain('InboundHistoryPanel')
expect(source).toContain('InboundMeasurementPanel')
expect(source).toContain('WeightOcrPanel')
expect(source).toContain('MeasurementReviewPanel')
expect(source).toContain('InboundFeePanel')
expect(source).toContain('getPlatformInbound')
expect(source).toContain('StatusBadge')
expect(source).toContain('label="基本信息"')
expect(source).toContain('label="货物与测量"')
expect(source).toContain('label="测量审核"')
expect(source).toContain('label="费用快照"')
expect(source).toContain('label="托盘与记录"')
})
it('uses the established first-leg detail density without nested framed surfaces', () => {
expect(source).toContain('padding: 20px 24px')
expect(source).toContain('border-bottom: 1px solid var(--cc-border)')
expect(source).not.toContain('border-radius: 10px')
expect(source).not.toContain('<el-tag')
})
it('refreshes the aggregate and remounts domains after manual or child refresh', () => {
expect(source).toContain('function handleDomainChanged()')
expect(source).toContain('async function refreshWorkspace()')
expect(source).toContain('revision.value += 1')
expect(source).toContain('void refreshWorkspace()')
expect(source).toContain('@click="refreshWorkspace"')
expect(source).toContain('@changed="handleDomainChanged"')
})
it('uses customer context in the page layer and maps it to compatible domain APIs', () => {
expect(source).toContain('customerId: string')
expect(source).toContain('getPlatformInbound(props.customerId, props.inboundId)')
expect(source).toContain(':tenant-id="customerId"')
expect(source).toContain('缺少所属企业信息')
expect(source).not.toContain('请选择企业')
expect(source).not.toContain('租户')
})
it('does not mount the review domain without its required measurement data permission', () => {
expect(source).toContain("access.can('platform:firstleg:inbound:measure:view')")
expect(source).toContain('v-if="canViewMeasurement"')
expect(source).toContain('无测量审核查看权限')
})
it('keeps pallet and location results read-only in the overview', () => {
expect(source).toContain(':pallet-locations="detail.palletLocations"')
expect(source).toContain('label="托盘与记录"')
expect(source).not.toContain('reserveLocation')
expect(source).not.toContain('allocatePallet')
})
})
@@ -0,0 +1,213 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { RefreshCw } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getPlatformInbound } from '../core/api'
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
import type { InboundOrderDetail } from '../core/types'
import { InboundMeasurementPanel } from '../measurement'
import { WeightOcrPanel } from '../weight'
import { MeasurementReviewPanel } from '../review'
import { InboundFeePanel } from '../fee'
const props = defineProps<{
customerId: string
inboundId: string
}>()
const emit = defineEmits<{
changed: []
}>()
const access = useAccessPreviewStore()
const activeTab = ref('overview')
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const detail = ref<InboundOrderDetail | null>(null)
const revision = ref(0)
let requestSerial = 0
let disposed = false
const title = computed(() => detail.value?.summary.inboundCode || props.inboundId)
const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const statusLabel = computed(() => {
const status = detail.value?.summary.processStatus || ''
return ({
WAIT_MEASURE: '待测量',
MEASURING: '测量中',
MEASURE_PENDING: '待审核',
MEASURE_REJECTED: '测量已驳回',
WAIT_PRICING: '待计费',
FEE_CONFIRMING: '费用确认中',
INBOUNDING: '入库处理中',
INBOUNDED: '已入库',
VOIDED: '已作废',
} as Record<string, string>)[status] || status || '未知状态'
})
async function loadDetail() {
const serial = ++requestSerial
if (!props.customerId || !props.inboundId) {
detail.value = null
loadError.value = null
loading.value = false
return
}
loading.value = true
loadError.value = null
try {
const response = await getPlatformInbound(props.customerId, props.inboundId)
if (disposed || serial !== requestSerial) return
detail.value = response
} catch (error) {
if (disposed || serial !== requestSerial) return
detail.value = null
loadError.value = getApiErrorDetails(error, '入库单详情加载失败')
} finally {
if (!disposed && serial === requestSerial) loading.value = false
}
}
async function refreshWorkspace() {
revision.value += 1
await loadDetail()
}
function handleDomainChanged() {
void refreshWorkspace()
emit('changed')
}
watch(
() => [props.customerId, props.inboundId],
() => {
activeTab.value = 'overview'
detail.value = null
loadError.value = null
revision.value += 1
void loadDetail()
},
{ immediate: true },
)
onBeforeUnmount(() => {
disposed = true
requestSerial += 1
})
</script>
<template>
<section class="inbound-workspace" :aria-busy="loading">
<header class="workspace-header">
<div>
<div class="workspace-title-row">
<h2>入仓码:{{ title }}</h2>
<StatusBadge v-if="detail" :status="statusLabel" />
</div>
<p>按基本信息、货物测量、审核、费用和托盘库位顺序处理,所有结果以服务端最新状态为准。</p>
</div>
<el-button :loading="loading" :disabled="!customerId || !inboundId" @click="refreshWorkspace">
<RefreshCw :size="16" />刷新
</el-button>
</header>
<LoadErrorState
v-if="loadError"
class="workspace-state"
title="入库作业详情暂时无法加载"
:message="loadError.message"
@retry="loadDetail"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<div v-else-if="loading && !detail" class="workspace-state workspace-skeleton" aria-label="正在加载入库作业详情">
<el-skeleton :rows="8" animated />
</div>
<el-empty v-else-if="!detail" class="workspace-state" description="缺少所属企业信息,无法查看入库作业详情" />
<el-tabs v-else v-model="activeTab" class="workflow-tabs">
<el-tab-pane label="基本信息" name="overview">
<InboundSummaryPanel :summary="detail.summary" />
</el-tab-pane>
<el-tab-pane label="货物与测量" name="measurement" lazy>
<div class="measurement-workflow">
<InboundMeasurementPanel
:key="`measurement-${revision}`"
:tenant-id="customerId"
:inbound-id="inboundId"
@changed="handleDomainChanged"
/>
<WeightOcrPanel
:key="`weight-${revision}`"
:tenant-id="customerId"
:inbound-id="inboundId"
@changed="handleDomainChanged"
/>
</div>
</el-tab-pane>
<el-tab-pane label="测量审核" name="review" lazy>
<MeasurementReviewPanel
v-if="canViewMeasurement"
:key="`review-${revision}`"
:tenant-id="customerId"
:inbound-id="inboundId"
@changed="handleDomainChanged"
/>
<el-alert
v-else
title="无测量审核查看权限"
description="当前账号缺少测量查看权限,无法读取审核所需的原单、箱规和称重数据。"
type="warning"
:closable="false"
show-icon
/>
</el-tab-pane>
<el-tab-pane label="费用快照" name="fee" lazy>
<InboundFeePanel
:key="`fee-${revision}`"
:tenant-id="customerId"
:inbound-id="inboundId"
@changed="handleDomainChanged"
/>
</el-tab-pane>
<el-tab-pane label="托盘与记录" name="records" lazy>
<InboundHistoryPanel
:pallet-locations="detail.palletLocations"
:files="detail.files"
:internal-notes="detail.internalNotes"
:status-history="detail.statusHistory"
:public-remark="detail.publicRemark"
/>
</el-tab-pane>
</el-tabs>
</section>
</template>
<style scoped>
.inbound-workspace { min-width: 0; }
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border); }
.workspace-title-row { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 10px; }
.workspace-title-row h2 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 18px; line-height: 1.35; }
.workspace-header p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.workspace-state { margin: 24px; }
.workspace-skeleton { padding: 0; }
.workflow-tabs { min-width: 0; padding: 0 24px 24px; }
.workflow-tabs :deep(.el-tabs__header) { margin-bottom: 16px; }
.workflow-tabs :deep(.el-tabs__item) { min-height: 48px; }
.measurement-workflow { display: grid; min-width: 0; gap: 28px; }
@media (max-width: 720px) {
.workspace-header { align-items: stretch; flex-direction: column; padding: 16px; }
.workspace-header :deep(.el-button) { width: 100%; min-height: 44px; }
.workspace-state { margin: 16px; }
.workflow-tabs { padding: 0 16px 16px; }
.workflow-tabs :deep(.el-tabs__nav-wrap) { overflow-x: auto; }
.workflow-tabs :deep(.el-tabs__item) { min-height: 44px; padding-inline: 14px; }
}
</style>
@@ -0,0 +1,76 @@
<script setup lang="ts">
import type { InboundFileSummary, InboundInternalNote, InboundPalletLocation, InboundStatusHistory } from './types'
defineProps<{
palletLocations: InboundPalletLocation[]
files: InboundFileSummary[]
internalNotes: InboundInternalNote[]
statusHistory: InboundStatusHistory[]
publicRemark: string | null
}>()
function value(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function number(value: number | null) { return value === null ? '—' : value.toFixed(3) }
function date(value: string | null) {
if (!value) return '—'
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false })
}
</script>
<template>
<div class="history-panel">
<section class="history-section">
<div class="section-heading"><h3>托盘与库位</h3><span>{{ palletLocations.length }} 条分配</span></div>
<div v-if="palletLocations.length" class="allocation-list">
<div v-for="item in palletLocations" :key="`${item.allocationNo}-${item.palletId}-${item.locationId}`" class="allocation-row">
<div><strong>{{ value(item.palletCode) }}</strong><span>分配 {{ value(item.allocationNo) }}</span></div>
<div><span>{{ number(item.allocatedVolumeCbm) }} CBM</span><span>{{ value(item.allocatedPackageCount) }} 箱</span></div>
<div class="allocation-location">{{ value(item.locationCode) }}</div>
</div>
</div>
<p v-else class="muted-empty">暂无托盘库位分配记录</p>
</section>
<section class="history-section">
<div class="section-heading"><h3>文件记录</h3><span>{{ files.length }} 个文件</span></div>
<div v-if="files.length" class="simple-list"><div v-for="file in files" :key="`${file.fileId}-${file.fileVersion}`"><strong>{{ value(file.fileNameSnapshot) }}</strong><span>{{ value(file.fileType) }} · {{ date(file.uploadedAt) }}</span></div></div>
<p v-else class="muted-empty">暂无文件记录</p>
</section>
<section class="history-section">
<div class="section-heading"><h3>内部备注</h3><span>{{ internalNotes.length }} 条</span></div>
<div v-if="internalNotes.length" class="simple-list"><div v-for="note in internalNotes" :key="String(note.id)"><strong>{{ value(note.createdByName || note.createdBy) }}</strong><span>{{ value(note.content) }} · {{ date(note.createdAt) }}</span></div></div>
<p v-else class="muted-empty">暂无内部备注</p>
</section>
<section class="history-section">
<div class="section-heading"><h3>状态历史</h3><span>{{ statusHistory.length }} 条</span></div>
<ol v-if="statusHistory.length" class="timeline"><li v-for="(history, index) in statusHistory" :key="`${history.occurredAt}-${index}`"><span class="timeline-dot" /><div><strong>{{ value(history.action) }}</strong><span>{{ value(history.fromStatus) }} → {{ value(history.toStatus) }} · {{ value(history.operatorName || history.operatorId) }}</span><small>{{ date(history.occurredAt) }}<template v-if="history.reason"> · {{ history.reason }}</template></small></div></li></ol>
<p v-else class="muted-empty">暂无状态历史</p>
</section>
<section v-if="publicRemark" class="history-section remark-section"><div class="section-heading"><h3>公开备注</h3></div><p>{{ publicRemark }}</p></section>
</div>
</template>
<style scoped>
.history-panel { display: grid; gap: 22px; min-width: 0; }
.history-section { min-width: 0; }
.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.section-heading h3 { margin: 0; color: var(--cc-text); font-size: 14px; }
.section-heading span { color: var(--cc-muted); font-size: 12px; }
.allocation-list, .simple-list { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; }
.allocation-row { display: grid; grid-template-columns: 1fr auto minmax(100px, 0.8fr); gap: 12px; align-items: center; padding: 11px 12px; border-bottom: 1px solid var(--cc-border); font-size: 12px; }
.allocation-row:last-child, .simple-list > div:last-child { border-bottom: 0; }
.allocation-row > div, .simple-list > div { min-width: 0; }
.allocation-row span, .simple-list span { display: block; margin-top: 3px; color: var(--cc-muted); }
.allocation-location { overflow-wrap: anywhere; color: var(--cc-primary); text-align: right; }
.simple-list > div { padding: 11px 12px; border-bottom: 1px solid var(--cc-border); font-size: 12px; }
.simple-list strong, .simple-list span { overflow-wrap: anywhere; }
.muted-empty { margin: 0; padding: 18px 12px; border: 1px dashed var(--cc-border); border-radius: 8px; color: var(--cc-muted); font-size: 12px; text-align: center; }
.timeline { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.timeline li { display: grid; grid-template-columns: 16px 1fr; gap: 10px; min-width: 0; padding-bottom: 16px; }
.timeline-dot { width: 8px; height: 8px; margin-top: 5px; border: 2px solid var(--cc-primary); border-radius: 50%; background: #fff; box-shadow: 0 0 0 3px color-mix(in srgb, var(--cc-primary) 12%, transparent); }
.timeline li > div { min-width: 0; padding-bottom: 12px; border-bottom: 1px solid var(--cc-border); }
.timeline strong, .timeline span, .timeline small { display: block; overflow-wrap: anywhere; }
.timeline span, .timeline small { margin-top: 3px; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.remark-section p { margin: 0; padding: 12px; border-radius: 8px; background: #f8fafc; color: var(--cc-text); font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
@media (max-width: 480px) { .allocation-row { grid-template-columns: 1fr auto; } .allocation-location { grid-column: 1 / -1; text-align: left; } }
</style>
@@ -0,0 +1,23 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/core/InboundSummaryPanel.vue'), 'utf8')
describe('InboundSummaryPanel', () => {
it('organizes the aggregate detail into the prototype-aligned backend-backed sections', () => {
expect(source).toContain('基本信息')
expect(source).toContain('仓库与运输')
expect(source).toContain('测量与计费')
expect(source).toContain('入库资源')
expect(source).toContain('summary.deliveryWarehouseName')
expect(source).toContain('summary.chargeableWeightKg')
expect(source).toContain('summary.locationCodes')
expect(source).toContain('summary.notificationStatus')
})
it('does not restore excluded receiving review or customer fee confirmation actions', () => {
expect(source).not.toContain('接收审核')
expect(source).not.toContain('客户确认费用')
})
})
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { Box, MapPin, Scale, Warehouse } from '@lucide/vue'
import type { InboundOrderSummary } from './types'
defineProps<{ summary: InboundOrderSummary }>()
const processLabels: Record<string, string> = {
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '测量驳回',
WAIT_PRICING: '待计费', FEE_CONFIRMING: '待发送费用', INBOUNDING: '入库处理中', INBOUNDED: '已入库', VOIDED: '已作废',
}
function processLabel(status: string) { return processLabels[status] || status || '未知状态' }
function processType(status: string) {
if (status === 'INBOUNDED') return 'success'
if (status === 'MEASURE_REJECTED' || status === 'VOIDED') return 'danger'
if (status === 'MEASURING' || status === 'INBOUNDING') return 'info'
return 'warning'
}
function value(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function number(value: number | null, digits = 3) { return value === null ? '—' : value.toFixed(digits) }
function date(value: string | null) {
if (!value) return '—'
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false })
}
function yesNo(value: boolean | null) { return value === null ? '—' : value ? '是' : '否' }
const measureReviewLabels: Record<string, string> = { PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' }
const feeLabels: Record<string, string> = { UNPRICED: '未计费', CALCULATED: '已试算', CONFIRMED: '已确认', SENT: '已发送', VOIDED: '已作废' }
const notificationLabels: Record<string, string> = { NOT_CREATED: '未创建', PENDING: '待发送', SUCCESS: '发送成功', FAILED: '发送失败' }
function label(value: string | null, labels: Record<string, string>) { return value ? labels[value] || value : '—' }
</script>
<template>
<section class="summary-panel" aria-label="入库单概览">
<div class="summary-heading">
<div>
<h3>{{ summary.inboundCode || `入库单 ${summary.id}` }}</h3>
<p class="summary-subtitle">订单 {{ value(summary.orderNo) }} · {{ value(summary.customerName) }}</p>
</div>
<el-tag :type="processType(summary.processStatus)" effect="light">{{ processLabel(summary.processStatus) }}</el-tag>
</div>
<p v-if="summary.holdFlag" class="hold-note" role="status">当前挂起:{{ summary.currentHoldReason || '待处理异常' }}</p>
<div class="summary-sections">
<section aria-labelledby="inbound-basic-heading">
<h4 id="inbound-basic-heading">基本信息</h4>
<dl class="summary-grid">
<div><dt>头程订单号</dt><dd>{{ value(summary.orderNo) }}</dd></div>
<div><dt>订单状态</dt><dd>{{ value(summary.orderStatusSnapshot) }}</dd></div>
<div><dt>客户</dt><dd>{{ value(summary.customerName) }}</dd></div>
<div><dt>公司</dt><dd>{{ value(summary.companyName) }}</dd></div>
<div><dt>业务类型</dt><dd>{{ value(summary.businessType) }}</dd></div>
<div><dt>渠道</dt><dd>{{ value(summary.channelName || summary.channelCode) }}</dd></div>
<div><dt>计税模式</dt><dd>{{ value(summary.taxMode) }}</dd></div>
<div><dt>装载模式</dt><dd>{{ value(summary.containerMode) }}</dd></div>
<div><dt>商检验</dt><dd>{{ yesNo(summary.customsInspection) }}</dd></div>
<div><dt>创建时间</dt><dd>{{ date(summary.createdAt) }}</dd></div>
</dl>
</section>
<section aria-labelledby="inbound-route-heading">
<h4 id="inbound-route-heading">仓库与运输</h4>
<dl class="summary-grid">
<div><dt><Warehouse :size="14" />收货仓</dt><dd>{{ value(summary.warehouseName) }}</dd></div>
<div><dt>交货仓</dt><dd>{{ value(summary.deliveryWarehouseName) }}</dd></div>
<div><dt><MapPin :size="14" />目的仓</dt><dd>{{ value(summary.destinationName || summary.destinationCode) }}</dd></div>
<div><dt>目的仓类型</dt><dd>{{ value(summary.destinationType) }}</dd></div>
<div><dt>集装箱号</dt><dd>{{ value(summary.containerNo) }}</dd></div>
<div><dt>柜号</dt><dd>{{ value(summary.cabinetNo) }}</dd></div>
<div><dt>船名 / 航次</dt><dd>{{ value(summary.vesselName) }} / {{ value(summary.voyageNo) }}</dd></div>
<div><dt>配载状态</dt><dd>{{ value(summary.loadingStatus) }}</dd></div>
<div><dt>预计到达</dt><dd>{{ date(summary.expectedArrivalAt) }}</dd></div>
<div><dt>开船 / 到港</dt><dd>{{ date(summary.sailingAt) }} / {{ date(summary.portArrivalAt) }}</dd></div>
<div><dt>快递公司</dt><dd>{{ value(summary.expressCompanyName || summary.expressCompanyCode) }}</dd></div>
<div><dt>快递单号</dt><dd>{{ value(summary.expressTrackingNo) }}</dd></div>
</dl>
</section>
<section aria-labelledby="inbound-measure-heading">
<h4 id="inbound-measure-heading">测量与计费</h4>
<dl class="summary-grid">
<div><dt><Box :size="14" />总箱数</dt><dd>{{ value(summary.totalPackageCount) }} 箱</dd></div>
<div><dt><Scale :size="14" />总毛重</dt><dd>{{ number(summary.actualGrossWeightKg) }} kg</dd></div>
<div><dt>总净重</dt><dd>{{ number(summary.actualNetWeightKg) }} kg</dd></div>
<div><dt>总体积</dt><dd>{{ number(summary.actualVolumeCbm) }} CBM</dd></div>
<div><dt>总材积重</dt><dd>{{ number(summary.volumeWeightKg) }} kg</dd></div>
<div><dt>计费重量</dt><dd>{{ number(summary.chargeableWeightKg) }} kg</dd></div>
<div><dt>计费体积</dt><dd>{{ number(summary.chargeableVolumeCbm) }} CBM</dd></div>
<div><dt>测量审核</dt><dd>{{ label(summary.measureReviewStatus, measureReviewLabels) }}</dd></div>
<div><dt>费用状态</dt><dd>{{ label(summary.feeStatus, feeLabels) }}</dd></div>
<div><dt>费用审核状态</dt><dd>{{ value(summary.feeReviewStatus) }}</dd></div>
<div><dt>测量完成时间</dt><dd>{{ date(summary.measureCompletedAt) }}</dd></div>
<div><dt>通知状态</dt><dd>{{ label(summary.notificationStatus, notificationLabels) }}</dd></div>
</dl>
</section>
<section aria-labelledby="inbound-storage-heading">
<h4 id="inbound-storage-heading">入库资源</h4>
<dl class="summary-grid">
<div><dt>总托盘数</dt><dd>{{ value(summary.palletCount) }}</dd></div>
<div><dt>库位位置</dt><dd>{{ value(summary.locationCodes) }}</dd></div>
<div><dt>入库完成时间</dt><dd>{{ date(summary.inboundedAt) }}</dd></div>
<div><dt>最后更新时间</dt><dd>{{ date(summary.updatedAt) }}</dd></div>
</dl>
</section>
</div>
</section>
</template>
<style scoped>
.summary-panel { min-width: 0; }
.summary-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
h3 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 18px; line-height: 1.3; }
.summary-subtitle { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; }
.summary-sections { display: grid; min-width: 0; gap: 22px; margin-top: 18px; }
.summary-sections section { min-width: 0; }
h4 { margin: 0 0 9px; color: var(--cc-text); font-size: 14px; line-height: 1.4; }
.summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
.summary-grid > div { min-width: 0; min-height: 66px; padding: 11px 12px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
dt { display: flex; align-items: center; gap: 6px; color: var(--cc-muted); font-size: 12px; }
dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; line-height: 1.5; }
.hold-note { margin: 16px 0 0; padding: 10px 12px; border: 1px solid #f5c26b; border-radius: 8px; background: #fffaeb; color: #93370d; font-size: 12px; line-height: 1.5; }
@media (max-width: 900px) { .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 480px) { .summary-heading { align-items: stretch; flex-direction: column; } .summary-grid { grid-template-columns: 1fr; } }
</style>
+86
View File
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getPlatformInbound, listPlatformInbounds } from './api'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
describe('platform inbound query API', () => {
beforeEach(() => vi.mocked(http.get).mockReset())
it('omits the backend tenant filter for an all-customer list query', async () => {
vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 },
})
await listPlatformInbounds(undefined, { page: 1, size: 20 })
expect(http.get).toHaveBeenCalledWith('/platform/inbounds', { params: { page: 1, size: 20 } })
})
it('uses the implemented non-v1 list path and normalizes page values and IDs', async () => {
vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: {
records: [{ id: 19001, orderId: 18001, customerId: 10001, totalPackageCount: '6', holdFlag: 'false' }],
page: '1', size: '20', total: '1', pages: '1',
},
})
await expect(listPlatformInbounds('10001', {
page: 1, size: 20, tab: 'WAIT_MEASURE', inboundCode: 'RK', holdFlag: false,
})).resolves.toMatchObject({
records: [{ id: '19001', orderId: '18001', customerId: '10001', totalPackageCount: 6, holdFlag: false }],
total: 1, pages: 1,
})
expect(http.get).toHaveBeenCalledWith('/platform/inbounds', {
params: { tenantId: '10001', page: 1, size: 20, tab: 'WAIT_MEASURE', inboundCode: 'RK', holdFlag: false },
})
})
it('loads a tenant-scoped aggregate detail and keeps nested IDs as strings', async () => {
vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: {
summary: { id: 19001, orderId: 18001 }, publicRemark: null,
palletLocations: [{ palletId: 21001, locationId: 22001 }], files: [{ fileId: 23001, uploadedBy: 2 }],
internalNotes: [{ id: 24001, createdBy: 2 }], statusHistory: [{ operatorId: 2 }],
},
})
await expect(getPlatformInbound('10001', '19001')).resolves.toMatchObject({
summary: { id: '19001', orderId: '18001' },
palletLocations: [{ palletId: '21001', locationId: '22001' }],
files: [{ fileId: '23001', uploadedBy: '2' }],
internalNotes: [{ id: '24001', createdBy: '2' }],
statusHistory: [{ operatorId: '2' }],
})
expect(http.get).toHaveBeenCalledWith('/platform/inbounds/19001', { params: { tenantId: '10001' } })
})
it('passes the implemented order, process, measurement and fee review filters without renaming them', async () => {
vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 },
})
await listPlatformInbounds('10001', {
page: 1,
size: 20,
processStatus: 'MEASURE_PENDING',
orderStatus: 'APPROVED',
measureReviewStatus: 'PENDING',
feeStatus: 'UNPRICED',
feeReviewStatus: 'PENDING',
warehouseId: '9007199254740993',
deliveryWarehouseId: '9007199254740995',
})
expect(http.get).toHaveBeenCalledWith('/platform/inbounds', {
params: {
tenantId: '10001', page: 1, size: 20, processStatus: 'MEASURE_PENDING',
orderStatus: 'APPROVED', measureReviewStatus: 'PENDING', feeStatus: 'UNPRICED', feeReviewStatus: 'PENDING',
warehouseId: '9007199254740993', deliveryWarehouseId: '9007199254740995',
},
})
})
})
+127
View File
@@ -0,0 +1,127 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
InboundFileSummary,
InboundInternalNote,
InboundListQuery,
InboundOrderDetail,
InboundOrderSummary,
InboundPageResult,
InboundPalletLocation,
InboundStatusHistory,
} from './types'
const basePath = '/platform/inbounds'
function id(value: unknown): string | null {
return value === null || value === undefined ? null : String(value)
}
function numberValue(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null
const parsed = typeof value === 'number' ? value : Number(value)
return Number.isFinite(parsed) ? parsed : null
}
function integerValue(value: unknown): number | null {
const parsed = numberValue(value)
return parsed === null ? null : Math.trunc(parsed)
}
function boolValue(value: unknown): boolean | null {
if (value === null || value === undefined) return null
if (typeof value === 'boolean') return value
if (value === 'true' || value === 1 || value === '1') return true
if (value === 'false' || value === 0 || value === '0') return false
return null
}
export function normalizeInboundSummary(raw: InboundOrderSummary): InboundOrderSummary {
return {
...raw,
id: String(raw.id),
orderId: id(raw.orderId),
customerId: id(raw.customerId),
companyId: id(raw.companyId),
warehouseId: id(raw.warehouseId),
deliveryWarehouseId: id(raw.deliveryWarehouseId),
destinationWarehouseId: id(raw.destinationWarehouseId),
version: integerValue(raw.version),
customsInspection: boolValue(raw.customsInspection),
holdFlag: boolValue(raw.holdFlag),
totalPackageCount: integerValue(raw.totalPackageCount),
actualGrossWeightKg: numberValue(raw.actualGrossWeightKg),
actualNetWeightKg: numberValue(raw.actualNetWeightKg),
actualVolumeCbm: numberValue(raw.actualVolumeCbm),
volumeWeightKg: numberValue(raw.volumeWeightKg),
chargeableWeightKg: numberValue(raw.chargeableWeightKg),
chargeableVolumeCbm: numberValue(raw.chargeableVolumeCbm),
palletCount: integerValue(raw.palletCount),
}
}
function normalizePage(raw: InboundPageResult): InboundPageResult {
return {
records: (raw.records || []).map(normalizeInboundSummary),
page: integerValue(raw.page) || 1,
size: integerValue(raw.size) || 20,
total: integerValue(raw.total) || 0,
pages: integerValue(raw.pages) || 0,
}
}
function normalizePallet(raw: InboundPalletLocation): InboundPalletLocation {
return {
...raw,
allocationNo: integerValue(raw.allocationNo),
warehouseId: id(raw.warehouseId),
palletId: id(raw.palletId),
locationId: id(raw.locationId),
palletCapacityCbm: numberValue(raw.palletCapacityCbm),
allocatedVolumeCbm: numberValue(raw.allocatedVolumeCbm),
allocatedPackageCount: integerValue(raw.allocatedPackageCount),
}
}
function normalizeFile(raw: InboundFileSummary): InboundFileSummary {
return { ...raw, fileId: id(raw.fileId), uploadedBy: id(raw.uploadedBy), fileVersion: integerValue(raw.fileVersion) }
}
function normalizeNote(raw: InboundInternalNote): InboundInternalNote {
return { ...raw, id: id(raw.id), createdBy: id(raw.createdBy) }
}
function normalizeHistory(raw: InboundStatusHistory): InboundStatusHistory {
return { ...raw, operatorId: id(raw.operatorId) }
}
function normalizeDetail(raw: InboundOrderDetail): InboundOrderDetail {
return {
...raw,
summary: normalizeInboundSummary(raw.summary),
palletLocations: (raw.palletLocations || []).map(normalizePallet),
files: (raw.files || []).map(normalizeFile),
internalNotes: (raw.internalNotes || []).map(normalizeNote),
statusHistory: (raw.statusHistory || []).map(normalizeHistory),
}
}
function queryParams(tenantId: string | undefined, query: InboundListQuery) {
return Object.fromEntries(
Object.entries({ tenantId, ...query }).filter(([, value]) => value !== undefined && value !== ''),
)
}
export async function listPlatformInbounds(tenantId: string | undefined, query: InboundListQuery) {
const response = await http.get<ApiEnvelope<InboundPageResult>, ApiEnvelope<InboundPageResult>>(basePath, {
params: queryParams(tenantId, query),
})
return normalizePage(unwrapApiResponse(response))
}
export async function getPlatformInbound(tenantId: string, inboundId: string) {
const response = await http.get<ApiEnvelope<InboundOrderDetail>, ApiEnvelope<InboundOrderDetail>>(
`${basePath}/${inboundId}`,
{ params: { tenantId } },
)
return normalizeDetail(unwrapApiResponse(response))
}
+153
View File
@@ -0,0 +1,153 @@
export type InboundListTab = 'WAIT_MEASURE' | 'WAIT_REVIEW' | 'WAIT_FEE_SEND' | 'INBOUNDED'
export type InboundProcessStatus =
| 'WAIT_MEASURE'
| 'MEASURING'
| 'MEASURE_PENDING'
| 'MEASURE_REJECTED'
| 'WAIT_PRICING'
| 'FEE_CONFIRMING'
| 'INBOUNDING'
| 'INBOUNDED'
| 'VOIDED'
export type InboundFeeStatus = 'UNPRICED' | 'CALCULATED' | 'CONFIRMED' | 'SENT' | 'VOIDED' | string
export interface InboundOrderSummary {
id: string
orderId: string | null
orderNo: string | null
inboundCode: string | null
processStatus: InboundProcessStatus | string
orderStatusSnapshot: string | null
measureReviewStatus: string | null
feeStatus: InboundFeeStatus | null
feeReviewStatus: string | null
notificationStatus: string | null
version: number | null
expectedArrivalAt: string | null
customerId: string | null
customerName: string | null
companyId: string | null
companyName: string | null
warehouseId: string | null
warehouseName: string | null
deliveryWarehouseId: string | null
deliveryWarehouseName: string | null
businessType: string | null
channelCode: string | null
channelName: string | null
taxMode: string | null
containerMode: string | null
customsInspection: boolean | null
destinationType: string | null
destinationWarehouseId: string | null
destinationCode: string | null
destinationName: string | null
holdFlag: boolean | null
currentHoldReason: string | null
totalPackageCount: number | null
actualGrossWeightKg: number | null
actualNetWeightKg: number | null
actualVolumeCbm: number | null
volumeWeightKg: number | null
chargeableWeightKg: number | null
chargeableVolumeCbm: number | null
palletCount: number | null
locationCodes: string | null
measureCompletedAt: string | null
inboundedAt: string | null
expressCompanyCode: string | null
expressCompanyName: string | null
expressTrackingNo: string | null
loadingStatus: string | null
containerNo: string | null
cabinetNo: string | null
vesselName: string | null
voyageNo: string | null
sailingAt: string | null
portArrivalAt: string | null
createdAt: string | null
updatedAt: string | null
}
export interface InboundPalletLocation {
allocationNo: number | null
warehouseId: string | null
palletId: string | null
palletCode: string | null
palletCapacityCbm: number | null
allocatedVolumeCbm: number | null
allocatedPackageCount: number | null
locationId: string | null
locationCode: string | null
reservationNo: string | null
allocationStatus: string | null
}
export interface InboundFileSummary {
fileId: string | null
fileType: string | null
fileNameSnapshot: string | null
visibility: string | null
fileVersion: number | null
uploadedBy: string | null
uploadedAt: string | null
}
export interface InboundInternalNote {
id: string | null
content: string | null
createdBy: string | null
createdByName: string | null
createdAt: string | null
}
export interface InboundStatusHistory {
fromStatus: string | null
toStatus: string | null
action: string | null
reason: string | null
operatorId: string | null
operatorName: string | null
occurredAt: string | null
}
export interface InboundOrderDetail {
summary: InboundOrderSummary
publicRemark: string | null
palletLocations: InboundPalletLocation[]
files: InboundFileSummary[]
internalNotes: InboundInternalNote[]
statusHistory: InboundStatusHistory[]
}
export interface InboundPageResult {
records: InboundOrderSummary[]
page: number
size: number
total: number
pages: number
}
export interface InboundListQuery {
page: number
size: number
tab?: InboundListTab
inboundCode?: string
orderNo?: string
processStatus?: string
orderStatus?: string
measureReviewStatus?: string
customerName?: string
destinationCode?: string
feeStatus?: string
feeReviewStatus?: string
customerId?: string
holdFlag?: boolean
companyId?: string
warehouseId?: string
deliveryWarehouseId?: string
expectedArrivalFrom?: string
expectedArrivalTo?: string
}
@@ -0,0 +1,237 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import {
adjustPublishedInboundFee,
getLatestInboundFee,
} from './api'
import type { InboundFeeSnapshot } from './types'
import InboundFeePanel from './InboundFeePanel.vue'
vi.mock('./api', async () => {
const actual = await vi.importActual<typeof import('./api')>('./api')
return {
...actual,
getLatestInboundFee: vi.fn(),
adjustPublishedInboundFee: vi.fn(),
}
})
const snapshot: InboundFeeSnapshot = {
feeId: '9007199254740993', feeNo: 'FBAF-88-2', feeVersion: 2, feeStatus: 'SENT',
inboundId: '88', inboundCode: 'RK202608040088',
chargeRuleId: '101', ruleCode: 'RULE-FBA-KG', chargeRuleVersion: 3,
carrierRateId: '202', rateCode: 'RATE-LAX', carrierRateVersion: 5,
customerId: '303', customerName: '测试客户', originWarehouseId: '404', originWarehouseName: '义乌仓',
businessType: 'FBA_SEA', channelCode: 'SEA-TAX', carrierId: '505', carrierName: '测试船司',
destinationCode: 'LAX9', taxMode: 'TAX_INCLUDED', chargeMode: 'KG',
conversionFactor: 167, roundingScale: 2, roundingMode: 'HALF_UP',
actualWeightKg: 100, actualVolumeCbm: 0.8, convertedQuantity: 133.6, chargeableQuantity: 133.6,
chargeUnit: 'KG', unitPrice: 8.5, subtotalAmount: 1135.6, additionalAmount: 100,
totalAmount: 1235.6, currency: 'CNY',
rateEffectiveFrom: '2026-08-01T00:00:00', rateEffectiveTo: null,
calculatedAt: '2026-08-04T10:00:00', confirmedAt: '2026-08-04T10:01:00', sentAt: '2026-08-04T10:02:00',
nextProcessStatus: 'INBOUNDED',
}
enableAutoUnmount(afterEach)
function setupPermissions(permissions: string[]) {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
return pinia
}
function mountPanel(permissions = ['firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust']) {
const pinia = setupPermissions(permissions)
return mount(InboundFeePanel, {
attachTo: document.body,
props: { customerId: '10001', inboundId: '88' },
global: { plugins: [pinia, ElementPlus] },
})
}
describe('InboundFeePanel', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
vi.mocked(getLatestInboundFee).mockResolvedValue(snapshot)
vi.mocked(adjustPublishedInboundFee).mockResolvedValue({ ...snapshot, feeVersion: 3, feeNo: 'FBAF-88-3' })
})
it('shows the immutable calculation basis, versions, amount, and timestamps', async () => {
mountPanel()
await flushPromises()
expect(getLatestInboundFee).toHaveBeenCalledWith('10001', '88')
expect(document.body.textContent).not.toContain('租户')
expect(document.body.textContent).toContain('对客费用总额 · CNY')
expect(document.body.textContent).toContain('¥1,235.60')
expect(document.body.textContent).toContain('基础运费小计 ¥1,135.60')
expect(document.body.textContent).toContain('附加费 ¥100.00')
expect(document.body.textContent).toContain('133.6 KG')
expect(document.body.textContent).toContain('按重量计费')
expect(document.body.textContent).toContain('¥8.50 / KG')
expect(document.body.textContent).toContain('RULE-FBA-KG · V3')
expect(document.body.textContent).toContain('RATE-LAX · V5')
expect(document.body.textContent).toContain('发布给客户')
expect(document.body.textContent).not.toContain('重新试算')
expect(document.body.textContent).not.toContain('发送费用')
})
it('follows the admin detail interaction pattern without list-only scaffolding', async () => {
const wrapper = mountPanel()
await flushPromises()
expect(wrapper.get('button[aria-label="刷新费用快照"]').classes()).toContain('is-circle')
expect(wrapper.get('.version-status .el-tag').text()).toBe('已发布')
expect(wrapper.find('.query-toolbar').exists()).toBe(false)
expect(wrapper.find('.data-table').exists()).toBe(false)
expect(wrapper.find('.surface-panel').exists()).toBe(false)
})
it('keeps the legacy integration prop compatible while the page layer uses customer context', async () => {
const pinia = setupPermissions(['firstleg:fba-pricing:view'])
mount(InboundFeePanel, {
props: { tenantId: 'legacy-10001', inboundId: '88' },
global: { plugins: [pinia, ElementPlus] },
})
await flushPromises()
expect(getLatestInboundFee).toHaveBeenCalledWith('legacy-10001', '88')
expect(document.body.textContent).not.toContain('租户')
})
it('keeps the prototype-aligned detail order and renders long values with pending timestamps', async () => {
const longRuleCode = `RULE-${'LONG-'.repeat(20)}`
const longCarrierName = `船司${'超长名称'.repeat(20)}`
vi.mocked(getLatestInboundFee).mockResolvedValue({
...snapshot,
ruleCode: longRuleCode,
carrierName: longCarrierName,
confirmedAt: null,
sentAt: null,
})
const wrapper = mountPanel()
await flushPromises()
const content = wrapper.find('.fee-content').text()
expect(wrapper.findAll('.fee-summary > div > span').map((node) => node.text())).toEqual([
'对客费用总额 · CNY',
'最终计费量',
'快照版本',
])
expect(wrapper.findAll('.calculation-flow > div > span').map((node) => node.text())).toEqual([
'实际重量',
'实际体积',
'折算量',
'取大后的计费量',
])
expect(wrapper.findAll('.fee-detail-grid h4').map((node) => node.text())).toEqual([
'客户计费规则',
'船司 FBA 价格',
])
expect(content).toContain(longRuleCode)
expect(content).toContain(longCarrierName)
const pendingTimelineItems = wrapper.findAll('.fee-timeline li.pending')
expect(pendingTimelineItems).toHaveLength(2)
expect(pendingTimelineItems.map((item) => item.find('strong').text())).toEqual(['—', '—'])
})
it('does not request or expose fees without the view permission', async () => {
mountPanel([])
await flushPromises()
expect(getLatestInboundFee).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('没有查看入库费用的权限')
})
it('treats A0404 as a neutral not-yet-generated state', async () => {
vi.mocked(getLatestInboundFee).mockRejectedValue(new ApiResponseError('费用快照不存在', 'A0404'))
mountPanel(['firstleg:fba-pricing:view'])
await flushPromises()
expect(document.body.textContent).toContain('尚未生成费用快照')
expect(document.body.textContent).toContain('测量审核完成后')
expect(document.querySelector('[role="alert"]')).toBeNull()
})
it('shows adjustment only for authorized published inbound fees', async () => {
mountPanel(['firstleg:fba-pricing:view'])
await flushPromises()
expect(document.querySelector('[data-testid="adjust-fee-button"]')).toBeNull()
document.body.innerHTML = ''
mountPanel()
await flushPromises()
expect(document.querySelector('[data-testid="adjust-fee-button"]')).not.toBeNull()
})
it('submits a reasoned adjustment and emits the refreshed snapshot', async () => {
const wrapper = mountPanel()
await flushPromises()
;(document.querySelector('[data-testid="adjust-fee-button"]') as HTMLButtonElement).click()
await flushPromises()
const numbers = [...document.querySelectorAll<HTMLInputElement>('.adjustment-form input')]
numbers[0]!.value = '1200'
numbers[0]!.dispatchEvent(new Event('input', { bubbles: true }))
numbers[0]!.dispatchEvent(new Event('change', { bubbles: true }))
numbers[1]!.value = '80'
numbers[1]!.dispatchEvent(new Event('input', { bubbles: true }))
numbers[1]!.dispatchEvent(new Event('change', { bubbles: true }))
const reason = document.querySelector<HTMLTextAreaElement>('.adjustment-form textarea')!
reason.value = '补充已确认的附加费用'
reason.dispatchEvent(new Event('input', { bubbles: true }))
reason.dispatchEvent(new Event('blur', { bubbles: true }))
const confirm = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.includes('确认调整并生成新版本'))
confirm?.click()
await flushPromises()
expect(adjustPublishedInboundFee).toHaveBeenCalledWith(
'10001',
'88',
{ subtotalAmount: 1200, additionalAmount: 80, reason: '补充已确认的附加费用' },
expect.stringMatching(/^fee-adjust-/),
)
expect(wrapper.emitted('changed')?.[0]?.[0]).toMatchObject({ feeVersion: 3 })
})
it('refreshes the latest snapshot after a conflict while retaining the dialog', async () => {
vi.mocked(adjustPublishedInboundFee).mockRejectedValue(
new ApiResponseError('费用已被其他操作员修改,请刷新后重试', 'A0409', 409),
)
mountPanel()
await flushPromises()
;(document.querySelector('[data-testid="adjust-fee-button"]') as HTMLButtonElement).click()
await flushPromises()
const numbers = [...document.querySelectorAll<HTMLInputElement>('.adjustment-form input')]
numbers[0]!.value = '1200'
numbers[0]!.dispatchEvent(new Event('input', { bubbles: true }))
numbers[1]!.value = '80'
numbers[1]!.dispatchEvent(new Event('input', { bubbles: true }))
const reason = document.querySelector<HTMLTextAreaElement>('.adjustment-form textarea')!
reason.value = '并发场景价格修正'
reason.dispatchEvent(new Event('input', { bubbles: true }))
reason.dispatchEvent(new Event('blur', { bubbles: true }))
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.includes('确认调整并生成新版本'))?.click()
await flushPromises()
expect(getLatestInboundFee).toHaveBeenCalledTimes(2)
expect(document.body.textContent).toContain('系统已刷新最新快照')
expect(document.body.textContent).toContain('当前输入仍保留')
})
})
@@ -0,0 +1,477 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { CircleDollarSign, PencilLine, RefreshCw } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import {
ApiResponseError,
getApiErrorDetails,
type ApiErrorDetails,
} from '@/shared/api/http'
import {
adjustPublishedInboundFee,
createFeeAdjustmentIdempotencyKey,
getLatestInboundFee,
} from './api'
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
const props = defineProps<{
customerId?: string
tenantId?: string
inboundId: string
}>()
const emit = defineEmits<{
changed: [snapshot: InboundFeeSnapshot]
}>()
const access = useAccessPreviewStore()
const loading = ref(false)
const adjusting = ref(false)
const notFound = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const adjustmentError = ref<ApiErrorDetails | null>(null)
const conflictRefreshed = ref(false)
const snapshot = ref<InboundFeeSnapshot | null>(null)
const adjustOpen = ref(false)
const adjustFormRef = ref<FormInstance>()
let loadSequence = 0
let adjustmentFingerprint = ''
let adjustmentIdempotencyKey = ''
const adjustmentForm = reactive({
subtotalAmount: 0,
additionalAmount: 0,
reason: '',
})
const adjustmentRules: FormRules = {
subtotalAmount: [{ required: true, message: '请输入调整后的基础运费', trigger: 'change' }],
additionalAmount: [{ required: true, message: '请输入调整后的附加费', trigger: 'change' }],
reason: [
{ required: true, whitespace: true, message: '请填写费用调整原因', trigger: 'blur' },
{ min: 2, max: 500, message: '调整原因需为 2–500 个字符', trigger: 'blur' },
],
}
const canView = computed(() => access.can('firstleg:fba-pricing:view'))
const canAdjust = computed(() => access.can('firstleg:fba-pricing:adjust'))
const customerContextId = computed(() => props.customerId || props.tenantId || '')
const adjustmentEligible = computed(() => (
snapshot.value?.feeStatus === 'SENT'
&& snapshot.value.nextProcessStatus === 'INBOUNDED'
))
const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费')
const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM')
const statusInfo = computed(() => feeStatusInfo(snapshot.value?.feeStatus))
function feeStatusInfo(status?: string) {
const map: Record<string, { label: string; type: 'success' | 'warning' | 'info' }> = {
CALCULATED: { label: '已试算', type: 'warning' },
CONFIRMED: { label: '已确认', type: 'info' },
SENT: { label: '已发布', type: 'success' },
SUPERSEDED: { label: '历史版本', type: 'info' },
}
return map[status || ''] || { label: status || '未知状态', type: 'info' as const }
}
function formatNumber(value: number | null | undefined, digits = 3) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: digits }).format(Number(value))
}
function formatMoney(value: number | null | undefined, currency?: string) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'
try {
return new Intl.NumberFormat('zh-CN', {
style: 'currency', currency: currency || 'CNY', minimumFractionDigits: 2,
}).format(Number(value))
} catch {
return `${formatNumber(value, 2)} ${currency || ''}`.trim()
}
}
function formatDateTime(value: string | null | undefined) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value.replace('T', ' ')
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).format(date)
}
function taxModeLabel(value: string) {
return value === 'TAX_INCLUDED' ? '包税' : value === 'TAX_EXCLUDED' ? '不包税' : value
}
function roundingModeLabel(value: string) {
const labels: Record<string, string> = {
UP: '远离零方向', DOWN: '向零方向', CEILING: '向上', FLOOR: '向下',
HALF_UP: '四舍五入', HALF_DOWN: '五舍六入', HALF_EVEN: '银行家舍入',
}
return labels[value] || value
}
async function loadFee(options: { preserveAdjustment?: boolean } = {}) {
const sequence = ++loadSequence
loadError.value = null
notFound.value = false
if (!customerContextId.value || !props.inboundId || !canView.value) {
snapshot.value = null
loading.value = false
return
}
loading.value = true
try {
const result = await getLatestInboundFee(customerContextId.value, props.inboundId)
if (sequence !== loadSequence) return
snapshot.value = result
if (!options.preserveAdjustment && adjustOpen.value) fillAdjustmentForm(result)
} catch (error) {
if (sequence !== loadSequence) return
snapshot.value = null
if (error instanceof ApiResponseError && error.code === 'A0404') notFound.value = true
else loadError.value = getApiErrorDetails(error, '费用快照加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
function fillAdjustmentForm(value: InboundFeeSnapshot) {
adjustmentForm.subtotalAmount = Number(value.subtotalAmount)
adjustmentForm.additionalAmount = Number(value.additionalAmount)
adjustmentForm.reason = ''
adjustmentError.value = null
conflictRefreshed.value = false
adjustmentFingerprint = ''
adjustmentIdempotencyKey = ''
}
function openAdjustment() {
if (!snapshot.value || !adjustmentEligible.value) return
fillAdjustmentForm(snapshot.value)
adjustOpen.value = true
}
function adjustmentPayload(): AdjustPublishedInboundFeeRequest {
return {
subtotalAmount: Number(adjustmentForm.subtotalAmount),
additionalAmount: Number(adjustmentForm.additionalAmount),
reason: adjustmentForm.reason.trim(),
}
}
function getAdjustmentKey(payload: AdjustPublishedInboundFeeRequest) {
const fingerprint = JSON.stringify(payload)
if (!adjustmentIdempotencyKey || fingerprint !== adjustmentFingerprint) {
adjustmentFingerprint = fingerprint
adjustmentIdempotencyKey = createFeeAdjustmentIdempotencyKey()
}
return adjustmentIdempotencyKey
}
async function submitAdjustment() {
if (!snapshot.value) return
const valid = await adjustFormRef.value?.validate().catch(() => false)
if (!valid) return
const payload = adjustmentPayload()
adjustmentError.value = null
conflictRefreshed.value = false
adjusting.value = true
try {
const result = await adjustPublishedInboundFee(
customerContextId.value,
props.inboundId,
payload,
getAdjustmentKey(payload),
)
snapshot.value = result
adjustOpen.value = false
ElMessage.success(`费用已调整为第 ${result.feeVersion} 版`)
emit('changed', result)
} catch (error) {
adjustmentError.value = getApiErrorDetails(error, '费用调整失败,请检查后重试')
if (error instanceof ApiResponseError && error.code === 'A0409') {
await loadFee({ preserveAdjustment: true })
conflictRefreshed.value = true
adjustmentFingerprint = ''
adjustmentIdempotencyKey = ''
}
} finally {
adjusting.value = false
}
}
watch(
() => [customerContextId.value, props.inboundId, access.permissionSignature],
() => {
adjustOpen.value = false
snapshot.value = null
void loadFee()
},
{ immediate: true },
)
defineExpose({ reload: loadFee })
</script>
<template>
<section class="inbound-fee-panel" :aria-busy="loading" aria-labelledby="inbound-fee-title">
<header class="fee-heading">
<div class="fee-heading-copy">
<span class="fee-heading-icon" aria-hidden="true"><CircleDollarSign :size="20" /></span>
<div>
<h3 id="inbound-fee-title">入库费用快照</h3>
<p>展示审核完成后锁定的计费依据和对客费用,不在此重复触发试算或发布。</p>
</div>
</div>
<div class="fee-heading-actions">
<el-tooltip content="刷新费用快照" placement="top">
<el-button
circle
aria-label="刷新费用快照"
:loading="loading"
:disabled="!customerContextId || !inboundId || !canView"
@click="loadFee()"
><RefreshCw :size="16" /></el-button>
</el-tooltip>
<el-button
v-if="canAdjust && adjustmentEligible"
data-testid="adjust-fee-button"
type="primary"
@click="openAdjustment"
><PencilLine :size="16" />调整已发布费用</el-button>
</div>
</header>
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
<template #title>当前账号没有查看入库费用的权限</template>
<p>需要权限:firstleg:fba-pricing:view</p>
</el-alert>
<div v-else-if="loading && !snapshot" class="fee-loading" aria-live="polite">
<el-skeleton :rows="6" animated />
<span class="sr-only">正在加载费用快照</span>
</div>
<el-alert v-else-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title>费用快照加载失败</template>
<p>{{ loadError.message }}</p>
<ApiErrorMeta :details="loadError" />
<el-button size="small" @click="loadFee()">重新加载</el-button>
</el-alert>
<el-empty v-else-if="notFound" :image-size="64" description="尚未生成费用快照">
<p class="empty-note">测量审核完成后,系统会自动生成并发布费用。</p>
</el-empty>
<div v-else-if="snapshot" class="fee-content">
<div class="fee-summary" aria-label="费用关键结果">
<div class="fee-total">
<span>对客费用总额 · {{ snapshot.currency }}</span>
<strong>{{ formatMoney(snapshot.totalAmount, snapshot.currency) }}</strong>
<small>基础运费小计 {{ formatMoney(snapshot.subtotalAmount, snapshot.currency) }} · 附加费 {{ formatMoney(snapshot.additionalAmount, snapshot.currency) }}</small>
</div>
<div class="fee-summary-item">
<span>最终计费量</span>
<strong>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong>
<small>{{ chargeModeLabel }}</small>
</div>
<div class="fee-summary-item">
<span>快照版本</span>
<div class="version-status"><strong>V{{ snapshot.feeVersion }}</strong><el-tag :type="statusInfo.type" effect="light">{{ statusInfo.label }}</el-tag></div>
<small>{{ snapshot.feeNo }}</small>
</div>
</div>
<section class="calculation-section" aria-labelledby="calculation-title">
<div class="section-title">
<div><h4 id="calculation-title">计费过程</h4><p>实际量与折算量取大后计费,金额以服务端快照为准。</p></div>
<el-tag effect="plain">{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
</div>
<div class="calculation-flow">
<div><span>实际重量</span><strong>{{ formatNumber(snapshot.actualWeightKg, 3) }} KG</strong></div>
<div><span>实际体积</span><strong>{{ formatNumber(snapshot.actualVolumeCbm, 4) }} CBM</strong></div>
<div><span>折算量</span><strong>{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong><small>系数 {{ formatNumber(snapshot.conversionFactor, 4) }}</small></div>
<div class="is-chargeable"><span>取大后的计费量</span><strong>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong></div>
</div>
<div class="amount-equation" aria-label="费用计算公式">
<span>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</span>
<i>×</i>
<span>{{ formatMoney(snapshot.unitPrice, snapshot.currency) }} / {{ chargeUnitLabel }}</span>
<i>+</i>
<span>附加费 {{ formatMoney(snapshot.additionalAmount, snapshot.currency) }}</span>
<i>=</i>
<strong>{{ formatMoney(snapshot.totalAmount, snapshot.currency) }}</strong>
</div>
</section>
<div class="fee-detail-grid">
<section aria-labelledby="rule-title">
<div class="section-title"><div><h4 id="rule-title">客户计费规则</h4><p>{{ snapshot.ruleCode }} · V{{ snapshot.chargeRuleVersion }}</p></div></div>
<dl>
<div><dt>客户</dt><dd>{{ snapshot.customerName }}</dd></div>
<div><dt>业务类型</dt><dd>{{ snapshot.businessType }}</dd></div>
<div><dt>计费口径</dt><dd>{{ chargeModeLabel }}</dd></div>
<div><dt>舍入规则</dt><dd>保留 {{ snapshot.roundingScale }} 位,{{ roundingModeLabel(snapshot.roundingMode) }}</dd></div>
</dl>
</section>
<section aria-labelledby="rate-title">
<div class="section-title"><div><h4 id="rate-title">船司 FBA 价格</h4><p>{{ snapshot.rateCode }} · V{{ snapshot.carrierRateVersion }}</p></div></div>
<dl>
<div><dt>起运仓</dt><dd>{{ snapshot.originWarehouseName }}</dd></div>
<div><dt>渠道 / 船司</dt><dd>{{ snapshot.channelCode }} / {{ snapshot.carrierName }}</dd></div>
<div><dt>FBA 目的仓</dt><dd>{{ snapshot.destinationCode }}</dd></div>
<div><dt>价格有效期</dt><dd>{{ formatDateTime(snapshot.rateEffectiveFrom) }} 至 {{ formatDateTime(snapshot.rateEffectiveTo) }}</dd></div>
</dl>
</section>
</div>
<section class="fee-timeline" aria-labelledby="fee-timeline-title">
<div class="section-title"><div><h4 id="fee-timeline-title">快照时间</h4><p>每个节点均由后端状态机记录。</p></div></div>
<ol>
<li><span>试算</span><strong>{{ formatDateTime(snapshot.calculatedAt) }}</strong></li>
<li :class="{ pending: !snapshot.confirmedAt }"><span>确认</span><strong>{{ formatDateTime(snapshot.confirmedAt) }}</strong></li>
<li :class="{ pending: !snapshot.sentAt }"><span>发布给客户</span><strong>{{ formatDateTime(snapshot.sentAt) }}</strong></li>
</ol>
</section>
</div>
<AppDialog
v-model="adjustOpen"
title="调整已发布费用"
width="min(560px, calc(100vw - 28px))"
confirm-text="确认调整并生成新版本"
confirm-type="warning"
:confirm-loading="adjusting"
@confirm="submitAdjustment"
>
<el-alert type="warning" :closable="false" show-icon title="调整会生成新的已发布版本,不会回退实物入库状态。" />
<el-alert v-if="conflictRefreshed" class="adjustment-alert" type="warning" :closable="false" show-icon>
<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="subtotalAmount" required>
<el-input-number v-model="adjustmentForm.subtotalAmount" aria-label="调整后基础运费" :min="0" :max="999999999999.99" :precision="2" :step="100" controls-position="right" />
<span>{{ snapshot?.currency }}</span>
</el-form-item>
<el-form-item label="调整后附加费" prop="additionalAmount" required>
<el-input-number v-model="adjustmentForm.additionalAmount" aria-label="调整后附加费" :min="0" :max="999999999999.99" :precision="2" :step="10" controls-position="right" />
<span>{{ snapshot?.currency }}</span>
</el-form-item>
</div>
<div class="adjustment-total"><span>调整后总额</span><strong>{{ formatMoney(adjustmentForm.subtotalAmount + adjustmentForm.additionalAmount, snapshot?.currency) }}</strong></div>
<el-form-item label="调整原因" prop="reason" required>
<el-input v-model="adjustmentForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="说明价格变更、补充费用或修正原因,记录将进入审计历史" />
</el-form-item>
</el-form>
</AppDialog>
</section>
</template>
<style scoped>
.inbound-fee-panel { min-width: 0; color: var(--cc-text); }
.fee-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 16px; }
.fee-heading-copy { display: flex; min-width: 0; align-items: flex-start; gap: 12px; }
.fee-heading-icon { display: grid; width: 38px; height: 38px; flex: 0 0 38px; place-items: center; border-radius: 10px; background: #eef4ff; color: var(--cc-primary); }
.fee-heading h3, .section-title h4 { margin: 0; }
.fee-heading h3 { font-size: 16px; }
.fee-heading p, .section-title p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.fee-heading-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.fee-heading-actions .el-button { margin-left: 0; }
.fee-loading { padding: 20px 0; }
.inbound-fee-panel :deep(.el-alert p) { margin: 5px 0 0; line-height: 1.55; }
.inbound-fee-panel :deep(.el-alert .el-button) { margin-top: 8px; }
.empty-note { margin: 0; color: var(--cc-muted); font-size: 12px; }
.fee-content { border-top: 1px solid var(--cc-border); }
.fee-summary { display: grid; grid-template-columns: minmax(260px, 1.35fr) repeat(2, minmax(180px, 1fr)); border-bottom: 1px solid var(--cc-border); background: #f8fafc; }
.fee-total, .fee-summary-item { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 6px; padding: 20px; }
.fee-summary-item { border-left: 1px solid var(--cc-border); }
.fee-summary span, .fee-summary small { color: var(--cc-muted); font-size: 11px; }
.fee-summary strong { font-size: 18px; line-height: 1.25; overflow-wrap: anywhere; }
.fee-total strong { color: #0f5c49; font-size: 27px; letter-spacing: -.02em; }
.version-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.calculation-section, .fee-timeline { padding: 22px 0; border-bottom: 1px solid var(--cc-border); }
.section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.section-title h4 { font-size: 14px; }
.calculation-flow { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 16px; border: 1px solid var(--cc-border); border-radius: 12px; overflow: hidden; }
.calculation-flow > div { display: flex; min-width: 0; min-height: 88px; flex-direction: column; justify-content: center; gap: 6px; padding: 14px 16px; border-right: 1px solid var(--cc-border); }
.calculation-flow > div:last-child { border-right: 0; }
.calculation-flow span, .calculation-flow small { color: var(--cc-muted); font-size: 11px; }
.calculation-flow strong { font-size: 15px; overflow-wrap: anywhere; }
.calculation-flow .is-chargeable { background: #f0fdf9; }
.calculation-flow .is-chargeable strong { color: #087d70; }
.amount-equation { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 12px; padding: 12px 14px; border-radius: 8px; background: #f8fafc; color: #475467; font-size: 12px; }
.amount-equation i { color: #98a2b3; font-style: normal; }
.amount-equation strong { color: var(--cc-text); font-size: 15px; }
.fee-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-bottom: 1px solid var(--cc-border); }
.fee-detail-grid > section { min-width: 0; padding: 22px 20px 22px 0; }
.fee-detail-grid > section + section { padding-right: 0; padding-left: 20px; border-left: 1px solid var(--cc-border); }
.fee-detail-grid dl { margin: 14px 0 0; }
.fee-detail-grid dl > div { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 12px; padding: 9px 0; border-top: 1px solid #edf0f4; font-size: 12px; }
.fee-detail-grid dt { color: var(--cc-muted); }
.fee-detail-grid dd { min-width: 0; margin: 0; font-weight: 600; overflow-wrap: anywhere; }
.fee-timeline { border-bottom: 0; }
.fee-timeline ol { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0; margin: 18px 0 0; padding: 0; list-style: none; }
.fee-timeline li { position: relative; display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 0 18px 0 20px; }
.fee-timeline li::before { position: absolute; top: 4px; left: 0; width: 9px; height: 9px; border-radius: 50%; background: #12b76a; content: ''; }
.fee-timeline li::after { position: absolute; top: 8px; right: 0; left: 10px; z-index: -1; height: 1px; background: var(--cc-border); content: ''; }
.fee-timeline li:last-child::after { display: none; }
.fee-timeline li.pending::before { background: #98a2b3; }
.fee-timeline span { color: var(--cc-muted); font-size: 11px; }
.fee-timeline strong { font-size: 12px; overflow-wrap: anywhere; }
.adjustment-alert { margin-top: 12px; }
.adjustment-form { margin-top: 18px; }
.adjustment-amounts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.adjustment-amounts :deep(.el-form-item__content) { flex-wrap: nowrap; }
.adjustment-amounts :deep(.el-input-number) { width: 100%; }
.adjustment-amounts :deep(.el-form-item__content > span) { margin-left: 8px; color: var(--cc-muted); font-size: 12px; }
.adjustment-total { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin: 0 0 18px; padding: 13px 14px; border-radius: 8px; background: #f8fafc; }
.adjustment-total span { color: var(--cc-muted); font-size: 12px; }
.adjustment-total strong { font-size: 18px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 900px) {
.fee-summary { grid-template-columns: 1fr 1fr; }
.fee-total { grid-column: 1 / -1; }
.fee-summary-item:first-of-type { border-left: 0; }
.calculation-flow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.calculation-flow > div:nth-child(2) { border-right: 0; }
.calculation-flow > div:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); }
}
@media (max-width: 720px) {
.fee-heading { align-items: stretch; flex-direction: column; }
.fee-heading-actions { justify-content: flex-start; }
.fee-heading-actions .el-button:not(.is-circle) { flex: 1; min-height: 44px; }
.fee-summary, .calculation-flow, .fee-detail-grid, .fee-timeline ol, .adjustment-amounts { grid-template-columns: 1fr; }
.fee-summary-item { border-top: 1px solid var(--cc-border); border-left: 0; }
.calculation-flow > div { min-height: 76px; border-right: 0; border-bottom: 1px solid var(--cc-border); }
.calculation-flow > div:last-child { border-bottom: 0; }
.fee-detail-grid > section, .fee-detail-grid > section + section { padding: 20px 0; border-left: 0; }
.fee-detail-grid > section + section { border-top: 1px solid var(--cc-border); }
.fee-timeline ol { gap: 16px; }
.fee-timeline li { padding-left: 20px; }
.fee-timeline li::after { display: none; }
}
@media (max-width: 360px) {
.fee-heading-copy { gap: 8px; }
.fee-heading-icon { width: 34px; height: 34px; flex-basis: 34px; }
.fee-total, .fee-summary-item { padding: 16px 12px; }
.fee-total strong { font-size: 22px; }
.fee-detail-grid dl > div { grid-template-columns: 1fr; gap: 4px; }
.amount-equation { align-items: flex-start; flex-direction: column; }
.amount-equation i { display: none; }
}
</style>
+73
View File
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError, http } from '@/shared/api/http'
import {
adjustPublishedInboundFee,
createFeeAdjustmentIdempotencyKey,
getLatestInboundFee,
} from './api'
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(), put: vi.fn() } }
})
const snapshot = {
feeId: '9007199254740993',
feeNo: 'FBAF-88-2',
feeVersion: 2,
feeStatus: 'SENT',
inboundId: '88',
totalAmount: 1280,
}
describe('inbound fee API', () => {
beforeEach(() => vi.clearAllMocks())
it('loads the latest immutable fee snapshot with string IDs', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: snapshot })
await expect(getLatestInboundFee('10001', '88')).resolves.toEqual(snapshot)
expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/88/fees/latest', {
params: { tenantId: '10001' },
})
})
it('adjusts a published snapshot with the required idempotency key', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, feeVersion: 3 } })
const payload = { subtotalAmount: 1200, additionalAmount: 80, reason: '补充已确认的附加费' }
await expect(adjustPublishedInboundFee('10001', '88', payload, 'fee-adjust-1'))
.resolves.toMatchObject({ feeVersion: 3 })
expect(http.put).toHaveBeenCalledWith(
'/platform/v1/inbounds/88/fees/published',
payload,
{ params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-adjust-1' } },
)
})
it('preserves backend conflict metadata for stale-version recovery', async () => {
vi.mocked(http.put).mockResolvedValue({
code: 'A0409', message: '费用已被其他操作员修改,请刷新后重试', data: null,
requestId: 'request-fee-409', traceId: 'trace-fee-409',
})
const error = await adjustPublishedInboundFee(
'10001',
'88',
{ subtotalAmount: 1200, additionalAmount: 80, reason: '价格修正' },
'fee-adjust-2',
).catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({ code: 'A0409', requestId: 'request-fee-409', traceId: 'trace-fee-409' })
})
it('creates bounded unique keys for adjustment retries', () => {
const first = createFeeAdjustmentIdempotencyKey()
const second = createFeeAdjustmentIdempotencyKey()
expect(first).toMatch(/^fee-adjust-/)
expect(first.length).toBeLessThanOrEqual(128)
expect(second).not.toBe(first)
})
})
+35
View File
@@ -0,0 +1,35 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
function feeBasePath(inboundId: string) {
return `/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees`
}
export async function getLatestInboundFee(tenantId: string, inboundId: string) {
const response = await http.get<unknown, ApiEnvelope<InboundFeeSnapshot>>(
`${feeBasePath(inboundId)}/latest`,
{ params: { tenantId } },
)
return unwrapApiResponse(response)
}
export async function adjustPublishedInboundFee(
tenantId: string,
inboundId: string,
payload: AdjustPublishedInboundFeeRequest,
idempotencyKey: string,
) {
const response = await http.put<unknown, ApiEnvelope<InboundFeeSnapshot>>(
`${feeBasePath(inboundId)}/published`,
payload,
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
)
return unwrapApiResponse(response)
}
export function createFeeAdjustmentIdempotencyKey() {
const uuid = globalThis.crypto?.randomUUID?.()
if (uuid) return `fee-adjust-${uuid}`
return `fee-adjust-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
}
+13
View File
@@ -0,0 +1,13 @@
export { default as InboundFeePanel } from './InboundFeePanel.vue'
export {
adjustPublishedInboundFee,
createFeeAdjustmentIdempotencyKey,
getLatestInboundFee,
} from './api'
export type {
AdjustPublishedInboundFeeRequest,
InboundChargeMode,
InboundFeeSnapshot,
InboundFeeStatus,
} from './types'
+54
View File
@@ -0,0 +1,54 @@
export type InboundFeeStatus = 'CALCULATED' | 'CONFIRMED' | 'SENT' | 'SUPERSEDED' | string
export type InboundChargeMode = 'KG' | 'CBM' | string
export interface InboundFeeSnapshot {
feeId: string
feeNo: string
feeVersion: number
feeStatus: InboundFeeStatus
inboundId: string
inboundCode: string | null
chargeRuleId: string
ruleCode: string
chargeRuleVersion: number
carrierRateId: string
rateCode: string
carrierRateVersion: number
customerId: string
customerName: string
originWarehouseId: string
originWarehouseName: string
businessType: string
channelCode: string
carrierId: string
carrierName: string
destinationCode: string
taxMode: string
chargeMode: InboundChargeMode
conversionFactor: number
roundingScale: number
roundingMode: string
actualWeightKg: number
actualVolumeCbm: number
convertedQuantity: number
chargeableQuantity: number
chargeUnit: string
unitPrice: number | null
subtotalAmount: number
additionalAmount: number
totalAmount: number
currency: string
rateEffectiveFrom: string
rateEffectiveTo: string | null
calculatedAt: string
confirmedAt: string | null
sentAt: string | null
nextProcessStatus: string | null
}
export interface AdjustPublishedInboundFeeRequest {
subtotalAmount: number
additionalAmount: number
reason: string
}
@@ -0,0 +1,255 @@
import ElementPlus, { 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'
import { ApiResponseError } from '@/shared/api/http'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import {
getInboundMeasurement,
saveInboundMeasurementDraft,
submitInboundMeasurement,
} from './api'
import type { InboundMeasurementDetail } from './types'
import InboundMeasurementPanel from './InboundMeasurementPanel.vue'
vi.mock('./api', () => ({
getInboundMeasurement: vi.fn(),
saveInboundMeasurementDraft: vi.fn(),
submitInboundMeasurement: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size },
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
}
}
function measurement(overrides: Partial<InboundMeasurementDetail> = {}): InboundMeasurementDetail {
return {
inboundId: '7001',
inboundCode: 'RK202608040001',
companyName: '测试企业',
channelCode: 'SEA-FBA',
channelName: '美西海运',
processStatus: 'MEASURING',
measureReviewStatus: null,
recommendedPalletType: 'STANDARD',
reviewReason: null,
reviewerId: null,
reviewerName: null,
measurementId: '9001',
sourceType: 'THIRD_PARTY',
measurementStatus: 'RECEIVED',
version: 2,
originalDocumentNo: 'DOC-001',
originalGrossWeightKg: 118.5,
originalVolumeCbm: 0.8,
actualPackageCount: 10,
actualGrossWeightKg: 120,
actualVolumeCbm: 0.6,
volumeWeightKg: 100.2,
actualGoodsName: '蓝牙音箱',
items: [{
id: '9101',
itemNo: '001',
boxSpecification: '50×40×30cm',
packageCount: 10,
lengthCm: 50,
widthCm: 40,
heightCm: 30,
girthCm: 190,
unitWeightKg: 12,
unitVolumeCbm: 0.06,
totalWeightKg: 120,
totalVolumeCbm: 0.6,
volumeWeightKg: 100.2,
intelligentImageFileId: '88001',
overLimitWarning: null,
outerLengthCm: null,
outerWidthCm: null,
outerHeightCm: null,
remark: '设备自动回传',
}],
weightResults: [],
...overrides,
}
}
async function mountPanel(permissions = [
'platform:firstleg:inbound:measure:view',
'platform:firstleg:inbound:measure:edit',
'platform:firstleg:inbound:measure:submit',
]) {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(InboundMeasurementPanel, {
props: { customerId: '10001', inboundId: '7001' },
attachTo: document.body,
global: { plugins: [pinia, ElementPlus] },
})
await flushPromises()
return wrapper
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('InboundMeasurementPanel', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', createMemoryStorage())
vi.stubGlobal('sessionStorage', createMemoryStorage())
document.body.innerHTML = ''
vi.mocked(getInboundMeasurement).mockReset().mockResolvedValue(measurement())
vi.mocked(saveInboundMeasurementDraft).mockReset().mockResolvedValue({ measurementId: '9001', version: 3, status: 'DRAFT' })
vi.mocked(submitInboundMeasurement).mockReset().mockResolvedValue({
measurementId: '9001', version: 2, processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING',
recommendedPalletType: 'STANDARD', feeId: null, feeNo: null, feeStatus: null,
})
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
})
it('loads third-party facts and renders server-calculated values separately', async () => {
const wrapper = await mountPanel()
expect(getInboundMeasurement).toHaveBeenCalledWith('10001', '7001')
expect(wrapper.text()).toContain('第三方设备')
expect(wrapper.text()).toContain('RK202608040001')
expect(wrapper.text()).toContain('箱规周长')
expect(wrapper.text()).toContain('190 cm')
expect(wrapper.text()).toContain('0.6 CBM')
expect((wrapper.find('input[placeholder="请输入本箱规的补充说明"]').element as HTMLInputElement).value).toBe('设备自动回传')
})
it('follows the prototype grouping, labels, and editable field order', async () => {
const wrapper = await mountPanel()
const text = wrapper.text()
expect(text).toContain('原单数据')
expect(text).toContain('入库单测量数据明细')
expect(text).toContain('实测总箱数')
expect(text).toContain('实测总毛重')
expect(text).toContain('实测总体积')
expect(text).toContain('实测总材积重')
expect(text).not.toContain('租户')
const labels = wrapper.findAll('.measurement-item__grid label > span').map((label) => label.text())
expect(labels.slice(0, 8)).toEqual([
'明细编号 *',
'箱规 *',
'箱规长(cm)*',
'箱规宽(cm)*',
'箱规高(cm)*',
'箱数 *',
'每箱毛重(kg)*',
'智能图片文件 ID',
])
})
it('reuses the established first-leg detail surface and status patterns', async () => {
const wrapper = await mountPanel()
expect(wrapper.classes()).toContain('surface-panel')
expect(wrapper.find('.measurement-panel__heading').classes()).toContain('panel-heading')
expect(wrapper.find('.status-badge').text()).toBe('测量中')
})
it('saves only editable raw fields with the current measurement version', async () => {
const wrapper = await mountPanel()
await wrapper.find('input[placeholder="请输入实际货物名称"]').setValue('智能音箱')
const save = wrapper.findAll('button').find((button) => button.text().includes('保存草稿'))
await save?.trigger('click')
await flushPromises()
expect(saveInboundMeasurementDraft).toHaveBeenCalledOnce()
expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({
measurementId: '9001',
version: 2,
actualGoodsName: '智能音箱',
items: [expect.objectContaining({
itemNo: '001', packageCount: 10, lengthCm: 50, intelligentImageFileId: '88001',
})],
}))
const payload = vi.mocked(saveInboundMeasurementDraft).mock.calls[0][2]
expect(payload.items[0]).not.toHaveProperty('totalVolumeCbm')
})
it('preserves the edited form when the backend reports a version conflict', async () => {
vi.mocked(saveInboundMeasurementDraft).mockRejectedValueOnce(
new ApiResponseError('数据版本已变化,请刷新后重试', 'A0409', 200, null, 'req-conflict', 'trace-conflict'),
)
const wrapper = await mountPanel()
const goodsInput = wrapper.find('input[placeholder="请输入实际货物名称"]')
await goodsInput.setValue('保留我的修改')
const save = wrapper.findAll('button').find((button) => button.text().includes('保存草稿'))
await save?.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('数据版本已变化,当前修改尚未丢失')
expect(wrapper.text()).toContain('req-conflict')
expect((goodsInput.element as HTMLInputElement).value).toBe('保留我的修改')
expect(getInboundMeasurement).toHaveBeenCalledTimes(1)
})
it('requires the dirty draft to be saved before submitting for review', async () => {
const wrapper = await mountPanel()
await wrapper.find('input[placeholder="请输入实际货物名称"]').setValue('尚未保存')
const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核'))
await submit?.trigger('click')
await flushPromises()
expect(submitInboundMeasurement).not.toHaveBeenCalled()
expect(ElMessageBox.confirm).not.toHaveBeenCalled()
})
it('submits the saved version and emits changed after confirmation', async () => {
const wrapper = await mountPanel()
const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核'))
await submit?.trigger('click')
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
expect.stringContaining('准备托盘与库位占用'),
'提交测量审核',
expect.objectContaining({ confirmButtonText: '确认提交' }),
)
expect(submitInboundMeasurement).toHaveBeenCalledWith('10001', '7001', { measurementId: '9001', version: 2 })
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('clears the previous customer context before loading the next one', async () => {
const wrapper = await mountPanel()
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ inboundId: '7002', inboundCode: 'RK202608040002', companyName: '第二企业' }))
await wrapper.setProps({ customerId: '10002', inboundId: '7002' })
await flushPromises()
expect(getInboundMeasurement).toHaveBeenLastCalledWith('10002', '7002')
expect(wrapper.text()).toContain('RK202608040002')
expect(wrapper.text()).not.toContain('测试企业')
})
it('renders a permission-specific read-only state without requesting data', async () => {
const wrapper = await mountPanel([])
expect(wrapper.text()).toContain('无测量查看权限')
expect(getInboundMeasurement).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,635 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { AlertTriangle, Plus, RefreshCw, Ruler, Save, Send, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement } from './api'
import type {
InboundMeasurementDetail,
InboundMeasurementItem,
SaveInboundMeasurementDraftRequest,
SaveInboundMeasurementItemRequest,
} from './types'
const props = defineProps<{
customerId?: string
tenantId?: string
inboundId: string
}>()
const emit = defineEmits<{
changed: []
}>()
interface EditableMeasurementItem extends SaveInboundMeasurementItemRequest {
clientKey: string
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
}
interface MeasurementFormModel {
measurementId: string | null
version: number
originalDocumentNo: string
originalGrossWeightKg: number | null
originalVolumeCbm: number | null
actualGoodsName: string
items: EditableMeasurementItem[]
}
const access = useAccessPreviewStore()
const loading = ref(false)
const saving = ref(false)
const submitting = ref(false)
const detail = ref<InboundMeasurementDetail | null>(null)
const loadError = ref<ApiErrorDetails | null>(null)
const operationError = ref<ApiErrorDetails | null>(null)
const conflictDetected = ref(false)
const validationErrors = ref<Record<string, string[]>>({})
const baseline = ref('')
let requestSequence = 0
let itemSequence = 0
const form = reactive<MeasurementFormModel>({
measurementId: null,
version: 0,
originalDocumentNo: '',
originalGrossWeightKg: null,
originalVolumeCbm: null,
actualGoodsName: '',
items: [],
})
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const canEdit = computed(() => access.can('platform:firstleg:inbound:measure:edit'))
const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit'))
const customerId = computed(() => props.customerId || props.tenantId || '')
const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
const editableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
const mayMaintain = computed(() => canEdit.value && editableProcess.value && !loading.value && !submitting.value)
const maySubmit = computed(() => canSubmit.value && editableProcess.value && Boolean(form.measurementId) && !loading.value && !saving.value)
const snapshot = computed(() => JSON.stringify(form))
const isDirty = computed(() => Boolean(detail.value) && snapshot.value !== baseline.value)
const registrationOpen = computed(() => hasContext.value && canView.value && Boolean(detail.value))
const requiredPermission = computed(() => canEdit.value ? 'platform:firstleg:inbound:measure:edit' : null)
const sourceLabel = computed(() => ({ THIRD_PARTY: '第三方设备', MANUAL: '平台人工', OPENAPI: '第三方设备' }[detail.value?.sourceType || ''] || detail.value?.sourceType || '尚未测量'))
const sourceTagType = computed(() => detail.value?.sourceType === 'MANUAL' ? 'info' : 'primary')
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'p2-inbound-measurement-editor',
label: computed(() => `入库单 ${detail.value?.inboundCode || props.inboundId} 的测量草稿`),
isOpen: registrationOpen,
model: form,
requiredPermission,
})
function optionalText(value: string) {
const normalized = value.trim()
return normalized || null
}
function nextItemKey() {
itemSequence += 1
return `measurement-item-${itemSequence}`
}
function emptyItem(): EditableMeasurementItem {
return {
clientKey: nextItemKey(),
itemNo: '',
boxSpecification: '',
packageCount: 1,
lengthCm: 0,
widthCm: 0,
heightCm: 0,
unitWeightKg: 0,
intelligentImageFileId: null,
overLimitWarning: null,
outerLengthCm: null,
outerWidthCm: null,
outerHeightCm: null,
remark: null,
serverValues: { girthCm: null, unitVolumeCbm: null, totalWeightKg: null, totalVolumeCbm: null, volumeWeightKg: null },
}
}
function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
return {
clientKey: nextItemKey(),
itemNo: item.itemNo,
boxSpecification: item.boxSpecification,
packageCount: item.packageCount,
lengthCm: item.lengthCm,
widthCm: item.widthCm,
heightCm: item.heightCm,
unitWeightKg: item.unitWeightKg,
intelligentImageFileId: item.intelligentImageFileId == null ? null : String(item.intelligentImageFileId),
overLimitWarning: item.overLimitWarning,
outerLengthCm: item.outerLengthCm,
outerWidthCm: item.outerWidthCm,
outerHeightCm: item.outerHeightCm,
remark: item.remark,
serverValues: {
girthCm: item.girthCm,
unitVolumeCbm: item.unitVolumeCbm,
totalWeightKg: item.totalWeightKg,
totalVolumeCbm: item.totalVolumeCbm,
volumeWeightKg: item.volumeWeightKg,
},
}
}
function clearForm() {
form.measurementId = null
form.version = 0
form.originalDocumentNo = ''
form.originalGrossWeightKg = null
form.originalVolumeCbm = null
form.actualGoodsName = ''
form.items.splice(0)
baseline.value = JSON.stringify(form)
captureBaseline()
}
function applyDetail(resource: InboundMeasurementDetail) {
detail.value = resource
form.measurementId = resource.measurementId == null ? null : String(resource.measurementId)
form.version = resource.version ?? 0
form.originalDocumentNo = resource.originalDocumentNo || ''
form.originalGrossWeightKg = resource.originalGrossWeightKg
form.originalVolumeCbm = resource.originalVolumeCbm
form.actualGoodsName = resource.actualGoodsName || ''
form.items.splice(0, form.items.length, ...resource.items.map(editableItem))
baseline.value = JSON.stringify(form)
captureBaseline()
validationErrors.value = {}
conflictDetected.value = false
}
async function loadMeasurement() {
if (!hasContext.value || !canView.value) return
const sequence = ++requestSequence
loading.value = true
loadError.value = null
operationError.value = null
try {
const resource = await getInboundMeasurement(customerId.value, props.inboundId)
if (sequence !== requestSequence) return
applyDetail(resource)
} catch (error) {
if (sequence !== requestSequence) return
detail.value = null
clearForm()
loadError.value = getApiErrorDetails(error, '测量数据加载失败,请稍后重试')
} finally {
if (sequence === requestSequence) loading.value = false
}
}
async function reloadLatest() {
if (isDirty.value) {
try {
await ElMessageBox.confirm('加载最新版本会丢弃当前尚未保存的修改。确定继续吗?', '重新加载测量数据', {
type: 'warning',
confirmButtonText: '加载最新版本',
cancelButtonText: '继续编辑',
})
} catch {
return
}
}
await loadMeasurement()
}
function addItem() {
if (form.items.length >= 500) {
ElMessage.warning('单个测量批次最多包含 500 条箱规')
return
}
form.items.push(emptyItem())
}
async function removeItem(index: number) {
if (saving.value || submitting.value) return
if (form.items.length === 1) {
ElMessage.warning('测量草稿至少需要一条箱规')
return
}
form.items.splice(index, 1)
}
function isPositive(value: number | null) {
return value != null && Number.isFinite(value) && value > 0
}
function isNonNegative(value: number | null) {
return value == null || (Number.isFinite(value) && value >= 0)
}
function validateDraft() {
const errors: Record<string, string[]> = {}
const general: string[] = []
if (!form.items.length) general.push('请至少添加一条箱规')
if (form.items.length > 500) general.push('单个测量批次最多包含 500 条箱规')
if (form.originalDocumentNo.trim().length > 64) general.push('原始单据号不能超过 64 个字符')
if (form.actualGoodsName.trim().length > 255) general.push('实际品名不能超过 255 个字符')
if (!isNonNegative(form.originalGrossWeightKg)) general.push('申报毛重不能小于 0')
if (!isNonNegative(form.originalVolumeCbm)) general.push('申报体积不能小于 0')
form.items.forEach((item, index) => {
const itemErrors: string[] = []
if (!item.itemNo.trim()) itemErrors.push('请输入箱规序号')
else if (item.itemNo.trim().length > 32) itemErrors.push('箱规序号不能超过 32 个字符')
if (!item.boxSpecification.trim()) itemErrors.push('请输入箱规描述')
else if (item.boxSpecification.trim().length > 64) itemErrors.push('箱规描述不能超过 64 个字符')
if (!Number.isInteger(item.packageCount) || item.packageCount < 1 || item.packageCount > 1_000_000) itemErrors.push('箱数必须是 1 至 1,000,000 的整数')
if (!isPositive(item.lengthCm) || !isPositive(item.widthCm) || !isPositive(item.heightCm)) itemErrors.push('长、宽、高必须大于 0')
if (!isPositive(item.unitWeightKg)) itemErrors.push('单箱重量必须大于 0')
if (![item.outerLengthCm, item.outerWidthCm, item.outerHeightCm].every(isNonNegative)) itemErrors.push('外包装尺寸不能小于 0')
if (item.intelligentImageFileId && !/^\d+$/.test(item.intelligentImageFileId)) itemErrors.push('测量图片文件 ID 必须是正整数')
if ((item.overLimitWarning || '').length > 500 || (item.remark || '').length > 500) itemErrors.push('提示或备注不能超过 500 个字符')
if (itemErrors.length) errors[item.clientKey] = itemErrors.map((message) => `第 ${index + 1} 条:${message}`)
})
if (general.length) errors.general = general
validationErrors.value = errors
return Object.keys(errors).length === 0
}
function buildPayload(): SaveInboundMeasurementDraftRequest {
return {
measurementId: form.measurementId,
version: form.version,
originalDocumentNo: optionalText(form.originalDocumentNo),
originalGrossWeightKg: form.originalGrossWeightKg,
originalVolumeCbm: form.originalVolumeCbm,
actualGoodsName: optionalText(form.actualGoodsName),
items: form.items.map((item) => ({
itemNo: item.itemNo.trim(),
boxSpecification: item.boxSpecification.trim(),
packageCount: item.packageCount,
lengthCm: item.lengthCm,
widthCm: item.widthCm,
heightCm: item.heightCm,
unitWeightKg: item.unitWeightKg,
intelligentImageFileId: item.intelligentImageFileId || null,
overLimitWarning: optionalText(item.overLimitWarning || ''),
outerLengthCm: item.outerLengthCm,
outerWidthCm: item.outerWidthCm,
outerHeightCm: item.outerHeightCm,
remark: optionalText(item.remark || ''),
})),
}
}
function setOperationError(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
operationError.value = details
conflictDetected.value = details.code === 'A0409'
|| details.code === 'CONFLICT'
|| details.code === 'HTTP_409'
|| details.message.includes('版本')
|| details.message.includes('并发')
return details
}
async function saveDraft() {
if (!mayMaintain.value || !validateDraft()) return
saving.value = true
operationError.value = null
conflictDetected.value = false
try {
const saved = await saveInboundMeasurementDraft(customerId.value, props.inboundId, buildPayload())
form.measurementId = String(saved.measurementId)
form.version = saved.version
ElMessage.success('测量草稿已保存,计算结果已由服务端更新')
await loadMeasurement()
emit('changed')
} catch (error) {
const details = setOperationError(error, '测量草稿保存失败,请检查后重试')
if (!conflictDetected.value) ElMessage.error(details.message)
} finally {
saving.value = false
}
}
async function submitForReview() {
if (!maySubmit.value || !form.measurementId) return
if (isDirty.value) {
ElMessage.warning('请先保存当前修改,再提交审核')
return
}
try {
await ElMessageBox.confirm(
'提交后将校验测量图片和全部称重结果,并由后端准备托盘与库位占用。提交成功后本批次不能继续编辑。',
'提交测量审核',
{ type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '取消' },
)
} catch {
return
}
submitting.value = true
operationError.value = null
conflictDetected.value = false
try {
await submitInboundMeasurement(customerId.value, props.inboundId, {
measurementId: form.measurementId,
version: form.version,
})
ElMessage.success('测量数据已提交审核')
await loadMeasurement()
emit('changed')
} catch (error) {
const details = setOperationError(error, '测量数据提交失败,请检查图片和称重结果后重试')
if (!conflictDetected.value) ElMessage.error(details.message)
} finally {
submitting.value = false
}
}
function formatNumber(value: number | null | undefined, maximumFractionDigits = 3) {
if (value == null || !Number.isFinite(value)) return '—'
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value)
}
function processStatusLabel(status: string | undefined) {
return ({
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '审核驳回',
WAIT_PRICING: '待计费', FEE_CONFIRMING: '费用确认中', INBOUNDING: '入库中', INBOUNDED: '已入库', VOIDED: '已作废',
} as Record<string, string>)[status || ''] || status || '—'
}
watch(
() => [customerId.value, props.inboundId, canView.value] as const,
() => {
requestSequence += 1
detail.value = null
loadError.value = null
operationError.value = null
conflictDetected.value = false
validationErrors.value = {}
clearForm()
if (hasContext.value && canView.value) void loadMeasurement()
},
{ immediate: true },
)
</script>
<template>
<section class="surface-panel measurement-panel" aria-label="入库测量草稿">
<header class="panel-heading measurement-panel__heading">
<div>
<span class="measurement-panel__icon"><Ruler :size="18" aria-hidden="true" /></span>
<div>
<h2>测量与箱规</h2>
<p>维护箱规原始值;体积、材积重和汇总结果均由服务端计算。</p>
</div>
</div>
<div class="measurement-panel__actions">
<el-button :loading="loading" :disabled="!hasContext || !canView || saving || submitting" @click="reloadLatest">
<RefreshCw :size="15" />刷新
</el-button>
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft">
<Save :size="15" />保存草稿
</el-button>
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="!maySubmit" @click="submitForReview">
<Send :size="15" />提交审核
</el-button>
</div>
</header>
<el-empty v-if="!hasContext" description="请选择所属企业和入库单后维护测量数据" />
<LoadErrorState
v-else-if="!canView"
title="无测量查看权限"
message="当前账号缺少 platform:firstleg:inbound:measure:view 权限。"
:show-retry="false"
/>
<div v-else-if="loading && !detail" class="measurement-skeleton" aria-label="正在加载测量数据">
<el-skeleton :rows="8" animated />
</div>
<LoadErrorState
v-else-if="loadError && !detail"
title="测量数据加载失败"
:message="loadError.message"
:loading="loading"
@retry="loadMeasurement"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<template v-else-if="detail">
<div class="measurement-context">
<div class="measurement-context__identity">
<div>
<strong>{{ detail.inboundCode }}</strong>
<span>{{ detail.companyName }} · {{ detail.channelName || detail.channelCode || '未设置渠道' }}</span>
</div>
<div class="measurement-tags">
<el-tag :type="sourceTagType" effect="light">{{ sourceLabel }}</el-tag>
<StatusBadge :status="processStatusLabel(detail.processStatus)" />
<span>版本 {{ form.version }}</span>
</div>
</div>
<dl class="measurement-metrics">
<div><dt>实测总箱数</dt><dd>{{ formatNumber(detail.actualPackageCount, 0) }}</dd></div>
<div><dt>实测总毛重</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>T.K.GW</small></dd></div>
<div><dt>实测总体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, 4) }} <small>T.CBM</small></dd></div>
<div><dt>实测总材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>KG</small></dd></div>
</dl>
</div>
<el-alert v-if="!editableProcess" type="info" :closable="false" show-icon>
<template #title>当前状态为“{{ processStatusLabel(detail.processStatus) }}”,测量数据仅可查看</template>
测量草稿只允许在待测量、测量中或审核驳回状态维护。
</el-alert>
<el-alert v-else-if="!canEdit" type="info" :closable="false" show-icon title="当前账号仅可查看测量数据" />
<el-alert v-if="conflictDetected" type="warning" :closable="false" show-icon class="measurement-alert">
<template #title>数据版本已变化,当前修改尚未丢失</template>
<p>{{ operationError?.message }}</p>
<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>
<form class="measurement-form" @submit.prevent="saveDraft">
<fieldset :disabled="!mayMaintain || saving">
<legend class="sr-only">测量草稿</legend>
<section class="measurement-section">
<div class="measurement-section__heading">
<div><h3>原单数据</h3><p>原单值仅用于审核对比,不参与前端计算。</p></div>
</div>
<div class="measurement-form-grid">
<label><span>原单据号码(CKW)</span><el-input v-model="form.originalDocumentNo" maxlength="64" placeholder="请输入原单据号码" /></label>
<label><span>原单据毛重(T.K.GW)</span><el-input-number v-model="form.originalGrossWeightKg" :min="0" :precision="3" :controls="false" placeholder="0.000" /></label>
<label><span>原单据总体积(T.CBM)</span><el-input-number v-model="form.originalVolumeCbm" :min="0" :precision="4" :controls="false" placeholder="0.0000" /></label>
<label><span>实际品名</span><el-input v-model="form.actualGoodsName" maxlength="255" placeholder="请输入实际货物名称" /></label>
</div>
</section>
<section class="measurement-section measurement-items-section">
<div class="measurement-section__heading">
<div><h3>入库单测量数据明细</h3><p>最多 500 条;保存后由服务端重新计算箱规周长、单箱体积、总毛重、总体积和总材积重。</p></div>
<el-button v-if="canEdit" :disabled="!mayMaintain || form.items.length >= 500" @click="addItem"><Plus :size="15" />新增箱规</el-button>
</div>
<el-empty v-if="!form.items.length" description="暂无箱规明细">
<el-button v-if="mayMaintain" type="primary" @click="addItem"><Plus :size="15" />添加第一条箱规</el-button>
</el-empty>
<article v-for="(item, index) in form.items" :key="item.clientKey" class="measurement-item" :class="{ 'has-error': validationErrors[item.clientKey]?.length }">
<header>
<div><strong>箱规 {{ index + 1 }}</strong><span>{{ item.itemNo || '待填写序号' }} · {{ item.boxSpecification || '待填写箱规' }}</span></div>
<el-button v-if="canEdit" link type="danger" :disabled="!mayMaintain || form.items.length === 1" @click="removeItem(index)"><Trash2 :size="15" />删除</el-button>
</header>
<div class="measurement-item__grid">
<label><span>明细编号 *</span><el-input v-model="item.itemNo" maxlength="32" placeholder="例如 001" /></label>
<label><span>箱规 *</span><el-input v-model="item.boxSpecification" maxlength="64" placeholder="例如 50×40×30cm" /></label>
<label><span>箱规长(cm)*</span><el-input-number v-model="item.lengthCm" :min="0.001" :precision="3" :controls="false" /></label>
<label><span>箱规宽(cm)*</span><el-input-number v-model="item.widthCm" :min="0.001" :precision="3" :controls="false" /></label>
<label><span>箱规高(cm)*</span><el-input-number v-model="item.heightCm" :min="0.001" :precision="3" :controls="false" /></label>
<label><span>箱数 *</span><el-input-number v-model="item.packageCount" :min="1" :max="1000000" :step="1" step-strictly controls-position="right" /></label>
<label><span>每箱毛重(kg)*</span><el-input-number v-model="item.unitWeightKg" :min="0.001" :precision="3" :controls="false" /></label>
<label><span>智能图片文件 ID</span><el-input v-model="item.intelligentImageFileId" inputmode="numeric" placeholder="文件服务返回的 ID" /></label>
</div>
<details class="measurement-item__advanced">
<summary>外包装尺寸与补充说明</summary>
<div class="measurement-item__grid">
<label><span>外箱规长(cm)</span><el-input-number v-model="item.outerLengthCm" :min="0" :precision="3" :controls="false" /></label>
<label><span>外箱规宽(cm)</span><el-input-number v-model="item.outerWidthCm" :min="0" :precision="3" :controls="false" /></label>
<label><span>外箱规高(cm)</span><el-input-number v-model="item.outerHeightCm" :min="0" :precision="3" :controls="false" /></label>
<label class="span-two"><span>超限提醒</span><el-input v-model="item.overLimitWarning" maxlength="500" placeholder="设备或人工发现的尺寸异常" /></label>
<label class="span-two"><span>备注</span><el-input v-model="item.remark" maxlength="500" placeholder="请输入本箱规的补充说明" /></label>
</div>
</details>
<dl class="measurement-item__results" aria-label="服务端计算结果">
<div><dt>箱规周长</dt><dd>{{ formatNumber(item.serverValues.girthCm) }} cm</dd></div>
<div><dt>单箱体积</dt><dd>{{ formatNumber(item.serverValues.unitVolumeCbm, 4) }} CBM</dd></div>
<div><dt>总毛重</dt><dd>{{ formatNumber(item.serverValues.totalWeightKg) }} KG</dd></div>
<div><dt>总体积</dt><dd>{{ formatNumber(item.serverValues.totalVolumeCbm, 4) }} CBM</dd></div>
<div><dt>总材积重</dt><dd>{{ formatNumber(item.serverValues.volumeWeightKg) }} KG</dd></div>
</dl>
</article>
</section>
</fieldset>
</form>
<footer v-if="canEdit || canSubmit" class="measurement-panel__footer">
<span v-if="isDirty"><AlertTriangle :size="15" />有尚未保存的修改</span>
<span v-else>当前显示版本 {{ form.version }}</span>
<div>
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft"><Save :size="15" />保存草稿</el-button>
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="!maySubmit" @click="submitForReview"><Send :size="15" />提交审核</el-button>
</div>
</footer>
</template>
</section>
</template>
<style scoped>
.measurement-panel { overflow: hidden; }
.measurement-panel__heading > div:first-child { display: flex; min-width: 0; align-items: flex-start; gap: 11px; }
.measurement-panel__icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 8px; background: #eef4ff; color: var(--cc-primary); }
.measurement-panel h2, .measurement-panel h3 { margin: 0; color: var(--cc-text); }
.measurement-panel h2 { font-size: 16px; }
.measurement-panel h3 { font-size: 14px; }
.measurement-panel__heading p, .measurement-section__heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.measurement-panel__actions, .measurement-panel__footer > div { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button) { margin-left: 0; }
.measurement-skeleton { padding: 24px; }
.measurement-context { padding: 18px 20px; background: #fbfcfe; }
.measurement-context__identity { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.measurement-context__identity > div:first-child { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.measurement-context__identity strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 15px; }
.measurement-context__identity span { color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.measurement-tags { display: flex; flex: 0 0 auto; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 7px; }
.measurement-tags > span:last-child { color: var(--cc-muted); font-size: 11px; }
.measurement-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 16px 0 0; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.measurement-metrics > div { min-width: 0; padding: 13px 15px; border-right: 1px solid var(--cc-border); }
.measurement-metrics > div:last-child { border-right: 0; }
.measurement-metrics dt { color: var(--cc-muted); font-size: 11px; }
.measurement-metrics dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 17px; font-weight: 700; }
.measurement-metrics small { color: var(--cc-muted); font-size: 10px; font-weight: 500; }
.measurement-panel > :deep(.el-alert), .measurement-alert { margin: 16px 20px 0; width: auto; }
.measurement-alert p { margin: 5px 0 0; line-height: 1.55; }
.measurement-alert ul { margin: 7px 0 0; padding-left: 18px; font-size: 12px; line-height: 1.65; }
.measurement-alert .el-button { margin-top: 9px; }
.measurement-form fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
.measurement-section { padding: 22px 20px; border-top: 1px solid var(--cc-border); }
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
.measurement-section__heading > div { min-width: 0; }
.measurement-form-grid, .measurement-item__grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 15px; }
.measurement-form label, .measurement-item label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #344054; font-size: 12px; font-weight: 650; }
.measurement-form :deep(.el-input-number), .measurement-form :deep(.el-input) { width: 100%; }
.measurement-items-section { background: #fafbfc; }
.measurement-item { overflow: hidden; margin-top: 14px; border: 1px solid var(--cc-border); border-radius: 9px; background: #fff; }
.measurement-item.has-error { border-color: var(--el-color-danger-light-5); }
.measurement-item > header { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 15px; border-bottom: 1px solid var(--cc-border); background: #f8fafc; }
.measurement-item > header > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.measurement-item > header strong { color: var(--cc-text); font-size: 13px; }
.measurement-item > header span { overflow: hidden; color: var(--cc-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.measurement-item__grid { padding: 16px; }
.measurement-item__advanced { margin: 0 16px 16px; border-top: 1px solid var(--cc-border); }
.measurement-item__advanced summary { padding: 12px 0 0; color: var(--cc-primary); font-size: 12px; font-weight: 700; cursor: pointer; }
.measurement-item__advanced .measurement-item__grid { grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 14px 0 0; }
.measurement-item__advanced .span-two { grid-column: span 3; }
.measurement-item__results { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); background: #fbfcfe; }
.measurement-item__results > div { min-width: 0; padding: 11px 14px; border-right: 1px solid var(--cc-border); }
.measurement-item__results > div:last-child { border-right: 0; }
.measurement-item__results dt { color: var(--cc-muted); font-size: 10px; }
.measurement-item__results dd { margin: 4px 0 0; overflow-wrap: anywhere; color: #344054; font-size: 12px; font-weight: 700; }
.measurement-panel__footer { position: sticky; bottom: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 20px; border-top: 1px solid var(--cc-border); background: rgba(255, 255, 255, .97); box-shadow: 0 -8px 22px rgba(15, 23, 42, .05); }
.measurement-panel__footer > span { display: inline-flex; align-items: center; gap: 6px; color: var(--cc-muted); font-size: 12px; }
.measurement-panel__footer > span:has(svg) { color: #b54708; font-weight: 650; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
@media (max-width: 960px) {
.measurement-form-grid, .measurement-item__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.measurement-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.measurement-metrics > div:nth-child(2) { border-right: 0; }
.measurement-metrics > div:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); }
.measurement-item__results { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.measurement-item__results > div:nth-child(3) { border-right: 0; }
.measurement-item__results > div:nth-child(-n+3) { border-bottom: 1px solid var(--cc-border); }
}
@media (max-width: 720px) {
.measurement-panel__heading, .measurement-context__identity, .measurement-section__heading, .measurement-panel__footer { align-items: stretch; flex-direction: column; }
.measurement-panel__actions, .measurement-panel__actions :deep(.el-button), .measurement-panel__footer > div, .measurement-panel__footer :deep(.el-button) { width: 100%; }
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button), .measurement-section__heading :deep(.el-button) { min-height: 44px; }
.measurement-tags { justify-content: flex-start; }
.measurement-context, .measurement-section { padding-right: 14px; padding-left: 14px; }
.measurement-panel > :deep(.el-alert), .measurement-alert { margin-right: 14px; margin-left: 14px; }
.measurement-form-grid, .measurement-item__grid, .measurement-item__advanced .measurement-item__grid { grid-template-columns: 1fr; }
.measurement-item__advanced .span-two { grid-column: auto; }
.measurement-item__results { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.measurement-item__results > div { border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.measurement-item__results > div:nth-child(even) { border-right: 0; }
.measurement-item__results > div:last-child { border-bottom: 0; }
}
@media (max-width: 420px) {
.measurement-metrics, .measurement-item__results { grid-template-columns: 1fr; }
.measurement-metrics > div, .measurement-item__results > div { border-right: 0; border-bottom: 1px solid var(--cc-border); }
.measurement-metrics > div:last-child, .measurement-item__results > div:last-child { border-bottom: 0; }
.measurement-item > header { align-items: flex-start; }
}
</style>
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement } from './api'
import type { SaveInboundMeasurementDraftRequest } from './types'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), put: vi.fn(), post: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
const draft: SaveInboundMeasurementDraftRequest = {
measurementId: '9001',
version: 2,
originalDocumentNo: 'DOC-01',
originalGrossWeightKg: 120,
originalVolumeCbm: 1.2,
actualGoodsName: '蓝牙音箱',
items: [{
itemNo: '001',
boxSpecification: '50×40×30cm',
packageCount: 10,
lengthCm: 50,
widthCm: 40,
heightCm: 30,
unitWeightKg: 12,
intelligentImageFileId: '88001',
overLimitWarning: null,
outerLengthCm: null,
outerWidthCm: null,
outerHeightCm: null,
remark: null,
}],
}
describe('inbound measurement API', () => {
beforeEach(() => {
vi.mocked(http.get).mockReset()
vi.mocked(http.put).mockReset()
vi.mocked(http.post).mockReset()
})
it('maps the selected customer to the backend tenant compatibility parameter', async () => {
const detail = { inboundId: '7001', items: [], weightResults: [] }
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: detail })
await expect(getInboundMeasurement('10001', '7001')).resolves.toEqual(detail)
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/inbounds/7001/measurement', {
params: { tenantId: '10001' },
})
})
it('saves a versioned draft without inventing an idempotency header', async () => {
const saved = { measurementId: '9001', version: 3, status: 'DRAFT' }
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: saved })
await expect(saveInboundMeasurementDraft('10001', '7001', draft)).resolves.toEqual(saved)
expect(http.put).toHaveBeenCalledWith(
'/platform/v1/first-leg/inbounds/7001/measurement/draft',
draft,
{ params: { tenantId: '10001' } },
)
})
it('submits the server measurement id and optimistic-lock version', async () => {
const submitted = { measurementId: '9001', version: 3, processStatus: 'MEASURE_PENDING' }
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: submitted })
await expect(submitInboundMeasurement('10001', '7001', { measurementId: '9001', version: 3 })).resolves.toEqual(submitted)
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/first-leg/inbounds/7001/measurement/submit',
{ measurementId: '9001', version: 3 },
{ params: { tenantId: '10001' } },
)
})
})
+46
View File
@@ -0,0 +1,46 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
InboundMeasurementDetail,
InboundMeasurementDraftSaved,
InboundMeasurementSubmitted,
SaveInboundMeasurementDraftRequest,
SubmitInboundMeasurementRequest,
} from './types'
function measurementPath(inboundId: string) {
return `/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/measurement`
}
export async function getInboundMeasurement(customerId: string, inboundId: string) {
const response = await http.get<ApiEnvelope<InboundMeasurementDetail>, ApiEnvelope<InboundMeasurementDetail>>(
measurementPath(inboundId),
{ params: { tenantId: customerId } },
)
return unwrapApiResponse(response)
}
export async function saveInboundMeasurementDraft(
customerId: string,
inboundId: string,
payload: SaveInboundMeasurementDraftRequest,
) {
const response = await http.put<ApiEnvelope<InboundMeasurementDraftSaved>, ApiEnvelope<InboundMeasurementDraftSaved>>(
`${measurementPath(inboundId)}/draft`,
payload,
{ params: { tenantId: customerId } },
)
return unwrapApiResponse(response)
}
export async function submitInboundMeasurement(
customerId: string,
inboundId: string,
payload: SubmitInboundMeasurementRequest,
) {
const response = await http.post<ApiEnvelope<InboundMeasurementSubmitted>, ApiEnvelope<InboundMeasurementSubmitted>>(
`${measurementPath(inboundId)}/submit`,
payload,
{ params: { tenantId: customerId } },
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,4 @@
export { default as InboundMeasurementPanel } from './InboundMeasurementPanel.vue'
export * from './api'
export type * from './types'
+131
View File
@@ -0,0 +1,131 @@
export type MeasurementIdentifier = string
export type InboundMeasurementProcessStatus =
| 'WAIT_MEASURE'
| 'MEASURING'
| 'MEASURE_PENDING'
| 'MEASURE_REJECTED'
| 'WAIT_PRICING'
| 'FEE_CONFIRMING'
| 'INBOUNDING'
| 'INBOUNDED'
| 'VOIDED'
export interface InboundMeasurementItem {
id: MeasurementIdentifier
itemNo: string
boxSpecification: string
packageCount: number
lengthCm: number
widthCm: number
heightCm: number
girthCm: number | null
unitWeightKg: number
unitVolumeCbm: number | null
totalWeightKg: number | null
totalVolumeCbm: number | null
volumeWeightKg: number | null
intelligentImageFileId: MeasurementIdentifier | null
overLimitWarning: string | null
outerLengthCm: number | null
outerWidthCm: number | null
outerHeightCm: number | null
remark: string | null
}
export interface InboundMeasurementWeightResult {
id: MeasurementIdentifier
fileId: MeasurementIdentifier
ocrProvider: string | null
ocrRawText: string | null
ocrWeightValue: number | null
ocrWeightUnit: string | null
confidence: number | null
adjustedWeightValue: number | null
adjustedWeightUnit: string | null
finalWeightKg: number | null
recognitionStatus: string
adjustmentReason: string | null
adjustedBy: MeasurementIdentifier | null
adjustedAt: string | null
confirmedBy: MeasurementIdentifier | null
confirmedAt: string | null
version: number
}
export interface InboundMeasurementDetail {
inboundId: MeasurementIdentifier
inboundCode: string
companyName: string
channelCode: string | null
channelName: string | null
processStatus: InboundMeasurementProcessStatus
measureReviewStatus: string | null
recommendedPalletType: string | null
reviewReason: string | null
reviewerId: MeasurementIdentifier | null
reviewerName: string | null
measurementId: MeasurementIdentifier | null
sourceType: string | null
measurementStatus: string | null
version: number | null
originalDocumentNo: string | null
originalGrossWeightKg: number | null
originalVolumeCbm: number | null
actualPackageCount: number | null
actualGrossWeightKg: number | null
actualVolumeCbm: number | null
volumeWeightKg: number | null
actualGoodsName: string | null
items: InboundMeasurementItem[]
weightResults: InboundMeasurementWeightResult[]
}
export interface SaveInboundMeasurementItemRequest {
itemNo: string
boxSpecification: string
packageCount: number
lengthCm: number
widthCm: number
heightCm: number
unitWeightKg: number
intelligentImageFileId: MeasurementIdentifier | null
overLimitWarning: string | null
outerLengthCm: number | null
outerWidthCm: number | null
outerHeightCm: number | null
remark: string | null
}
export interface SaveInboundMeasurementDraftRequest {
measurementId: MeasurementIdentifier | null
version: number
originalDocumentNo: string | null
originalGrossWeightKg: number | null
originalVolumeCbm: number | null
actualGoodsName: string | null
items: SaveInboundMeasurementItemRequest[]
}
export interface InboundMeasurementDraftSaved {
measurementId: MeasurementIdentifier
version: number
status: string
}
export interface SubmitInboundMeasurementRequest {
measurementId: MeasurementIdentifier
version: number
}
export interface InboundMeasurementSubmitted {
measurementId: MeasurementIdentifier
version: number
processStatus: string
measureReviewStatus: string
recommendedPalletType: string | null
feeId: MeasurementIdentifier | null
feeNo: string | null
feeStatus: string | null
}
@@ -0,0 +1,23 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/pages/InboundOrderDetailView.vue'), 'utf8')
describe('InboundOrderDetailView', () => {
it('maps the compatible route parameter to customer context without a detail-page selector', () => {
expect(source).toContain('route.query.tenantId')
expect(source).toContain('const customerId = computed')
expect(source).toContain("name: 'inbound-orders'")
expect(source).toContain(':customer-id="customerId"')
expect(source).toContain(':inbound-id="inboundId"')
expect(source).toContain('缺少所属企业信息,请从入库单列表重新打开')
expect(source).not.toContain('PlatformTenantSelector')
expect(source).not.toContain('租户')
expect(source).toContain('<h1>入库详情</h1>')
expect(source).toContain('货物测量、审核、费用及托盘库位记录')
expect(source).toContain('class="surface-panel detail-page"')
expect(source).toContain('class="detail-page-actions"')
expect(source).toContain('返回入库单列表')
})
})
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import InboundWorkflowWorkspace from '../components/InboundWorkflowWorkspace.vue'
const route = useRoute()
const router = useRouter()
function routeValue(value: unknown) {
if (Array.isArray(value)) return String(value[0] || '')
return value === null || value === undefined ? '' : String(value)
}
const inboundId = computed(() => routeValue(route.params.inboundId))
const customerId = computed(() => routeValue(route.query.tenantId))
function backToList() {
void router.push({ name: 'inbound-orders', query: customerId.value ? { tenantId: customerId.value } : undefined })
}
</script>
<template>
<div class="business-page inbound-detail-page">
<section class="surface-panel detail-page">
<header class="detail-page-header">
<div class="detail-page-heading">
<h1>入库详情</h1>
<p>查看基本信息、货物测量、审核、费用及托盘库位记录。</p>
</div>
<div class="detail-page-actions">
<el-button @click="backToList"><ArrowLeft :size="16" />返回入库单列表</el-button>
</div>
</header>
<div class="detail-page-content">
<InboundWorkflowWorkspace
v-if="inboundId && customerId"
:customer-id="customerId"
:inbound-id="inboundId"
/>
<el-empty
v-else
:description="inboundId ? '缺少所属企业信息,请从入库单列表重新打开' : '缺少有效的入库单标识'"
/>
</div>
</section>
</div>
</template>
<style scoped>
.inbound-detail-page { min-width: 0; }
.detail-page { overflow: hidden; }
.detail-page-header { display: flex; min-height: 88px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border); }
.detail-page-heading { min-width: 0; }
.detail-page-heading h1 { margin: 0; color: var(--cc-text); font-size: 20px; line-height: 1.35; }
.detail-page-heading p { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.detail-page-actions { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 12px; }
.detail-page-content { min-width: 0; }
@media (max-width: 720px) {
.detail-page-header { align-items: flex-start; flex-direction: column; padding: 16px; }
.detail-page-actions { width: 100%; align-items: stretch; flex-direction: column; }
.detail-page-actions :deep(.el-button) { width: 100%; min-height: 44px; }
}
</style>
@@ -0,0 +1,261 @@
import ElementPlus from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import InboundOrdersView from './InboundOrdersView.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import { listPlatformWarehouses } from '@/modules/warehouses'
import { getPlatformInbound, listPlatformInbounds } from '../core/api'
import type { InboundOrderDetail, InboundOrderSummary } from '../core/types'
vi.mock('../core/api', () => ({
getPlatformInbound: vi.fn(),
listPlatformInbounds: vi.fn(),
}))
vi.mock('@/modules/warehouses', () => ({
listPlatformWarehouses: vi.fn(),
}))
vi.mock('@/modules/iam', () => ({
listPlatformTenants: vi.fn(),
}))
const customers: PlatformTenantResource[] = [
{ id: '10001', tenantCode: 'CUS-001', tenantName: '示例企业', status: 'ACTIVE', legalName: '示例企业有限公司', legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, internalRemark: null, createdAt: null, approvedAt: null },
{ id: '10002', tenantCode: 'CUS-002', tenantName: '第二客户', status: 'ACTIVE', legalName: null, legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, internalRemark: null, createdAt: null, approvedAt: null },
]
const summary: InboundOrderSummary = {
id: '19001', orderId: '18001', orderNo: 'ORD-001', inboundCode: 'RK-001', processStatus: 'WAIT_MEASURE',
orderStatusSnapshot: 'APPROVED', measureReviewStatus: 'PENDING', feeStatus: 'UNPRICED', feeReviewStatus: null,
notificationStatus: null, version: 1, expectedArrivalAt: '2026-08-04T10:00:00', customerId: '10001', customerName: '示例客户',
companyId: '10001', companyName: '示例企业', warehouseId: '20001', warehouseName: '义乌收货仓', deliveryWarehouseId: null,
deliveryWarehouseName: null, businessType: 'FBA', channelCode: 'SEA', channelName: '海运', taxMode: 'DDP', containerMode: null,
customsInspection: false, destinationType: 'FBA', destinationWarehouseId: '30001', destinationCode: 'ONT8', destinationName: 'ONT8',
holdFlag: false, currentHoldReason: null, totalPackageCount: 6, actualGrossWeightKg: 120, actualNetWeightKg: 110,
actualVolumeCbm: 1.2, volumeWeightKg: 200, chargeableWeightKg: 200, chargeableVolumeCbm: null, palletCount: 1, locationCodes: 'A-01',
measureCompletedAt: null, inboundedAt: null, expressCompanyCode: null, expressCompanyName: null, expressTrackingNo: null,
loadingStatus: null, containerNo: null, cabinetNo: null, vesselName: null, voyageNo: null, sailingAt: null, portArrivalAt: null,
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00',
}
const detail: InboundOrderDetail = {
summary,
publicRemark: '请优先安排测量',
palletLocations: [{ allocationNo: 1, warehouseId: '20001', palletId: '21001', palletCode: 'P-001', palletCapacityCbm: 2, allocatedVolumeCbm: 1.2, allocatedPackageCount: 6, locationId: '22001', locationCode: 'A-01', reservationNo: 'RES-1', allocationStatus: 'PREPARED' }],
files: [], internalNotes: [], statusHistory: [{ fromStatus: null, toStatus: 'WAIT_MEASURE', action: 'CREATE', reason: null, operatorId: '2', operatorName: '管理员', occurredAt: '2026-08-01T10:00:00' }],
}
enableAutoUnmount(afterEach)
function mountPage() {
return mount(InboundOrdersView, {
attachTo: document.body,
global: { plugins: [ElementPlus] },
})
}
async function selectCustomer(wrapper: ReturnType<typeof mountPage>, customerId = '10001') {
await flushPromises()
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), customerId })
await flushPromises()
return toolbar
}
describe('InboundOrdersView', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getPlatformInbound).mockResolvedValue(detail)
vi.mocked(listPlatformTenants).mockResolvedValue({ records: customers, page: 1, size: 200, total: 2, pages: 1 })
vi.mocked(listPlatformWarehouses).mockResolvedValue({
records: [
{ id: '20001', tenantId: '10001', warehouseCode: 'YIWU', warehouseName: '义乌收货仓', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
{ id: 20002, tenantId: '10001', warehouseCode: 'B001', warehouseName: '交货仓 B001', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
],
page: 1, size: 200, total: 2, pages: 1,
})
})
it('loads the customer-scoped list and renders the first-column view action', async () => {
const wrapper = mountPage()
await flushPromises()
const toolbar = wrapper.findComponent(QueryToolbar)
const customerField = (toolbar.props('fields') as Array<{ key: string; placeholder?: string }>).find((field) => field.key === 'customerId')
expect(customerField?.placeholder).toBe('全部客户')
expect((toolbar.props('modelValue') as { customerId: string }).customerId).toBe('')
expect(listPlatformInbounds).toHaveBeenCalledWith(undefined, expect.objectContaining({ page: 1, size: 20 }))
expect(listPlatformWarehouses).not.toHaveBeenCalled()
const requestsBeforeSearch = vi.mocked(listPlatformInbounds).mock.calls.length
toolbar.vm.$emit('search')
await flushPromises()
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeSearch + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 1, size: 20 }))
await selectCustomer(wrapper)
expect(listPlatformInbounds).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
expect(listPlatformWarehouses).toHaveBeenCalledWith('10001', 1, 200)
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
expect(document.body.textContent).toContain('RK-001')
expect(document.body.textContent).toContain('查看')
expect(document.body.textContent).toContain('所属企业 示例企业有限公司')
expect(document.body.textContent).not.toContain('租户')
expect(wrapper.find('.inbound-tabs .is-active').text()).toContain('全部')
})
it('uses backend-supported warehouse IDs from tenant-scoped options without coercing Long IDs', async () => {
const wrapper = mountPage()
const toolbar = await selectCustomer(wrapper)
toolbar.vm.$emit('update:modelValue', {
...toolbar.props('modelValue'),
warehouseId: '20001',
deliveryWarehouseId: '20002',
})
toolbar.vm.$emit('search')
await flushPromises()
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({
warehouseId: '20001',
deliveryWarehouseId: '20002',
}))
})
it('keeps common filters compact and clears hidden advanced filters when collapsed', async () => {
const wrapper = mountPage()
const toolbar = await selectCustomer(wrapper)
const fieldKeys = () => (toolbar.props('fields') as Array<{ key: string }>).map((field) => field.key)
const widths = Object.fromEntries((toolbar.props('fields') as Array<{ key: string; width?: number }>).map((field) => [field.key, field.width]))
expect(widths.customerId).toBe(260)
expect(widths.processStatus).toBe(160)
expect(widths.inboundCode).toBe(220)
expect(widths.expectedArrivalRange).toBe(320)
expect(fieldKeys()).toContain('expectedArrivalRange')
expect(fieldKeys()).not.toContain('feeReviewStatus')
const advancedButton = () => wrapper.findAll('button').find((button) => button.text().includes('更多筛选') || button.text().includes('收起筛选'))
await advancedButton()?.trigger('click')
expect(fieldKeys()).toContain('feeReviewStatus')
toolbar.vm.$emit('update:modelValue', {
...toolbar.props('modelValue'),
feeReviewStatus: 'PENDING',
destinationCode: 'ONT8',
})
await flushPromises()
await advancedButton()?.trigger('click')
await flushPromises()
expect(fieldKeys()).not.toContain('feeReviewStatus')
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({
feeReviewStatus: undefined,
destinationCode: undefined,
}))
})
it('passes the selected backend tab and opens an aggregate read-only detail', async () => {
const wrapper = mountPage()
await selectCustomer(wrapper)
;[...document.querySelectorAll<HTMLButtonElement>('.inbound-tab')].find((button) => button.textContent?.includes('待审核'))?.click()
await flushPromises()
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({ tab: 'WAIT_REVIEW', page: 1 }))
;[...document.querySelectorAll<HTMLButtonElement>('button')].filter((button) => button.textContent?.trim() === '查看').at(-1)?.click()
await flushPromises()
expect(getPlatformInbound).toHaveBeenCalledWith('10001', '19001')
expect(document.body.textContent).toContain('托盘与库位')
expect(document.body.textContent).toContain('请优先安排测量')
wrapper.unmount()
})
it('uses the app header as the page title and exposes compact accessible status tabs', async () => {
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('.inbound-page-heading').exists()).toBe(false)
expect(wrapper.text()).not.toContain('所有处理均进入单票工作台完成')
const tabList = wrapper.find('[role="tablist"]')
const tabButtons = tabList.findAll('[role="tab"]')
const queryToolbar = wrapper.findComponent(QueryToolbar)
expect(tabList.attributes('aria-label')).toBe('入库单处理状态')
expect(tabList.element.parentElement?.classList.contains('table-toolbar')).toBe(true)
expect(queryToolbar.element.compareDocumentPosition(tabList.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(tabButtons).toHaveLength(5)
expect(wrapper.find('.table-toolbar').text()).toContain('共 1 条 · 所属企业 全部客户')
expect(wrapper.find('.table-toolbar').text()).toContain('刷新')
expect(wrapper.text()).not.toContain('全部入库单')
expect(tabButtons[0].attributes('aria-selected')).toBe('true')
expect(tabButtons[0].attributes('tabindex')).toBe('0')
await tabButtons[0].trigger('keydown', { key: 'ArrowRight' })
await flushPromises()
expect(tabButtons[1].attributes('aria-selected')).toBe('true')
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ tab: 'WAIT_MEASURE', page: 1 }))
})
it('shows an actionable list error and recovers on retry', async () => {
vi.mocked(listPlatformInbounds).mockRejectedValueOnce(new Error('network down')).mockResolvedValueOnce({ records: [summary], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = mountPage()
await flushPromises()
expect(document.body.textContent).toContain('入库单暂时无法加载')
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '重新加载')?.click()
await flushPromises()
expect(listPlatformInbounds).toHaveBeenCalledTimes(2)
expect(document.body.textContent).toContain('RK-001')
})
it('clears the selected list and detail when the selected customer changes', async () => {
const wrapper = mountPage()
const toolbar = await selectCustomer(wrapper)
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), customerId: '10002' })
await flushPromises()
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10002', expect.objectContaining({ page: 1 }))
expect(listPlatformWarehouses).toHaveBeenLastCalledWith('10002', 1, 200)
})
it('resets to all customers without sending an empty backend tenant ID', async () => {
const wrapper = mountPage()
const toolbar = await selectCustomer(wrapper)
const requestsBeforeReset = vi.mocked(listPlatformInbounds).mock.calls.length
const resetButton = wrapper.findAll('button').find((button) => button.text().includes('重置'))
await resetButton?.trigger('click')
await flushPromises()
expect((toolbar.props('modelValue') as { customerId: string }).customerId).toBe('')
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeReset + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 1 }))
expect(document.body.textContent).not.toContain('请选择所属企业')
expect(document.body.textContent).not.toContain('请选择客户')
})
it('loads another full-data page without a customer filter', async () => {
vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 60, pages: 3 })
const wrapper = mountPage()
await flushPromises()
wrapper.findComponent(DataTable).vm.$emit('update:page', 2)
await flushPromises()
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 2, size: 20 }))
})
it('keeps the operation column first and follows the prototype field order with backend-backed columns', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/pages/InboundOrdersView.vue'), 'utf8')
const toolbarSource = readFileSync(resolve(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8')
const columnSource = source.slice(source.indexOf('const columns:'))
const operation = columnSource.indexOf("key: 'actions'")
const inboundCode = columnSource.indexOf("key: 'inboundCode'")
const orderStatus = columnSource.indexOf("key: 'orderStatus'")
const measureReview = columnSource.indexOf("key: 'measureReviewStatus'")
const feeStatus = columnSource.indexOf("key: 'feeStatus'")
const customer = columnSource.indexOf("key: 'customerName'")
const warehouse = columnSource.indexOf("key: 'warehouseName'")
expect(operation).toBeGreaterThan(-1)
expect(operation).toBeLessThan(inboundCode)
expect(inboundCode).toBeLessThan(orderStatus)
expect(orderStatus).toBeLessThan(measureReview)
expect(measureReview).toBeLessThan(feeStatus)
expect(feeStatus).toBeLessThan(customer)
expect(customer).toBeLessThan(warehouse)
expect(columnSource).toContain("fixed: 'left'")
expect(toolbarSource).toMatch(/@media \(max-width: 720px\)[\s\S]*width: 100%/)
})
})
@@ -0,0 +1,474 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ChevronDown, ChevronUp, ClipboardCheck, Eye, RefreshCw, SlidersHorizontal } from '@lucide/vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformWarehouses } from '@/modules/warehouses'
import { getPlatformInbound, listPlatformInbounds } from '../core/api'
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
import type { InboundListTab, InboundOrderDetail, InboundOrderSummary } from '../core/types'
type FilterModel = QueryModel & {
customerId: string
inboundCode: string
orderNo: string
warehouseId: string
deliveryWarehouseId: string
processStatus: string
orderStatus: string
measureReviewStatus: string
customerName: string
destinationCode: string
feeStatus: string
feeReviewStatus: string
holdFlag: string
expectedArrivalRange: string[]
}
const router = useRouter()
const tab = ref<InboundListTab | ''>('')
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detail = ref<InboundOrderDetail | null>(null)
const detailTarget = ref<InboundOrderSummary | null>(null)
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', orderNo: '', warehouseId: '', deliveryWarehouseId: '', processStatus: '', orderStatus: '', measureReviewStatus: '', customerName: '', destinationCode: '', feeStatus: '', feeReviewStatus: '', holdFlag: '', expectedArrivalRange: [] })
const selectedCustomerId = ref('')
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const customerLoadError = ref<ApiErrorDetails | null>(null)
const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
const warehousesLoading = ref(false)
const warehousesUnavailable = ref(false)
const showAdvancedFilters = ref(false)
let loadRequestId = 0
let detailRequestId = 0
let customerRequestId = 0
let warehouseRequestId = 0
let disposed = false
const tabs: Array<{ value: InboundListTab | ''; label: string; description: string }> = [
{ value: '', label: '全部', description: '全部入库单' },
{ value: 'WAIT_MEASURE', label: '待测量', description: '等待测量或补正' },
{ value: 'WAIT_REVIEW', label: '待审核', description: '等待测量审核' },
{ value: 'WAIT_FEE_SEND', label: '待费用/入库', description: '等待费用编排完成' },
{ value: 'INBOUNDED', label: '已入库', description: '费用已发布并完成入库' },
]
const advancedFilterKeys = new Set(['orderStatus', 'feeReviewStatus', 'customerName', 'destinationCode', 'holdFlag'])
const allQueryFields = computed<QueryField[]>(() => [
{
key: 'customerId',
type: 'select',
width: 260,
placeholder: customerLoadError.value ? '所属企业加载失败' : customersLoading.value ? '正在加载客户' : '全部客户',
options: customers.value.map((customer) => ({
value: String(customer.id),
label: `${customer.legalName?.trim() || customer.tenantName}(${customer.tenantCode || '无客户编码'})`,
})),
props: { 'aria-label': '所属企业', loading: customersLoading.value, filterable: true, disabled: Boolean(customerLoadError.value) || customers.value.length === 0 },
},
{ key: 'warehouseId', type: 'select', width: 180, placeholder: warehousesUnavailable.value ? '定位仓库加载失败' : '定位仓库', options: warehouseOptions.value, props: { 'aria-label': '定位仓库', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
{ key: 'deliveryWarehouseId', type: 'select', width: 180, placeholder: warehousesUnavailable.value ? '交货仓加载失败' : '交货仓', options: warehouseOptions.value, props: { 'aria-label': '交货仓', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
{ key: 'inboundCode', type: 'input', width: 220, placeholder: '入仓码' },
{ key: 'orderNo', type: 'input', width: 220, placeholder: '头程订单号' },
{ key: 'processStatus', type: 'select', width: 160, placeholder: '流程状态', options: [
{ label: '待测量', value: 'WAIT_MEASURE' }, { label: '测量中', value: 'MEASURING' },
{ label: '待测量审核', value: 'MEASURE_PENDING' }, { label: '测量已驳回', value: 'MEASURE_REJECTED' },
{ label: '待计费', value: 'WAIT_PRICING' }, { label: '费用处理中', value: 'FEE_CONFIRMING' },
{ label: '入库处理中', value: 'INBOUNDING' }, { label: '已入库', value: 'INBOUNDED' }, { label: '已作废', value: 'VOIDED' },
] },
{ key: 'orderStatus', type: 'input', width: 200, placeholder: '订单状态码' },
{ key: 'measureReviewStatus', type: 'select', width: 160, placeholder: '测量审核状态', options: [
{ label: '待审核', value: 'PENDING' }, { label: '审核通过', value: 'APPROVED' }, { label: '审核驳回', value: 'REJECTED' },
] },
{ key: 'feeStatus', type: 'select', width: 160, placeholder: '费用状态', options: [
{ label: '未计费', value: 'UNPRICED' }, { label: '已试算', value: 'CALCULATED' },
{ label: '已确认', value: 'CONFIRMED' }, { label: '已发送', value: 'SENT' }, { label: '已作废', value: 'VOIDED' },
] },
{ key: 'feeReviewStatus', type: 'input', width: 200, placeholder: '费用审核状态码' },
{ key: 'customerName', type: 'input', width: 260, placeholder: '客户名称' },
{ key: 'destinationCode', type: 'input', width: 220, placeholder: '目的仓编码' },
{ key: 'holdFlag', type: 'select', width: 160, placeholder: '挂起状态', options: [{ label: '已挂起', value: 'true' }, { label: '未挂起', value: 'false' }] },
{ key: 'expectedArrivalRange', type: 'daterange', width: 320, placeholder: '预计到达日期' },
])
const queryFields = computed(() => showAdvancedFilters.value
? allQueryFields.value
: allQueryFields.value.filter((field) => !advancedFilterKeys.has(field.key)))
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 152, fixed: 'left', slotName: 'actions' },
{ key: 'inboundCode', label: '入仓码', minWidth: 166, slotName: 'inbound' },
{ key: 'orderStatus', label: '订单 / 流程状态', minWidth: 156, slotName: 'orderStatus' },
{ key: 'measureReviewStatus', label: '测量数据审核状态', minWidth: 142, slotName: 'measureReviewStatus' },
{ key: 'expectedArrivalAt', label: '预计到达时间', minWidth: 164, slotName: 'expectedArrivalAt' },
{ key: 'feeStatus', label: '费用状态', minWidth: 124, slotName: 'feeStatus' },
{ key: 'customerName', label: '客户', minWidth: 142, slotName: 'customerName' },
{ key: 'warehouseName', label: '仓库', minWidth: 140, slotName: 'warehouseName' },
{ key: 'deliveryWarehouseName', label: '交货仓', minWidth: 140, slotName: 'deliveryWarehouseName' },
{ key: 'loadingStatus', label: '配载状态', minWidth: 110, slotName: 'loadingStatus' },
{ key: 'holdFlag', label: '是否扣货', width: 96, slotName: 'holdFlag' },
{ key: 'currentHoldReason', label: '扣货理由', minWidth: 150, slotName: 'currentHoldReason' },
{ key: 'containerNo', label: '集装箱号', minWidth: 132, slotName: 'containerNo' },
{ key: 'cabinetNo', label: '柜号', minWidth: 118, slotName: 'cabinetNo' },
{ key: 'companyName', label: '公司', minWidth: 160, slotName: 'companyName' },
{ key: 'businessType', label: '业务类型', minWidth: 112, slotName: 'businessType' },
{ key: 'channelName', label: '渠道', minWidth: 138, slotName: 'channelName' },
{ key: 'taxMode', label: '计税模式', minWidth: 108, slotName: 'taxMode' },
{ key: 'containerMode', label: '装载模式', minWidth: 108, slotName: 'containerMode' },
{ key: 'customsInspection', label: '商检验', width: 92, slotName: 'customsInspection' },
{ key: 'sailingAt', label: '开船时间', minWidth: 164, slotName: 'sailingAt' },
{ key: 'portArrivalAt', label: '到港时间', minWidth: 164, slotName: 'portArrivalAt' },
{ key: 'totalPackageCount', label: '总箱数', width: 90, slotName: 'totalPackageCount' },
{ key: 'actualGrossWeightKg', label: '总毛重(kg)', minWidth: 118, slotName: 'actualGrossWeightKg' },
{ key: 'chargeableWeightKg', label: '计费重量(kg)', minWidth: 126, slotName: 'chargeableWeightKg' },
{ key: 'actualVolumeCbm', label: '总体积(CBM)', minWidth: 122, slotName: 'actualVolumeCbm' },
{ key: 'chargeableVolumeCbm', label: '计费体积(CBM)', minWidth: 132, slotName: 'chargeableVolumeCbm' },
{ key: 'palletCount', label: '总托盘数', width: 100, slotName: 'palletCount' },
{ key: 'locationCodes', label: '库位位置', minWidth: 124, slotName: 'locationCodes' },
{ key: 'volumeWeightKg', label: '总材积重(kg)', minWidth: 126, slotName: 'volumeWeightKg' },
{ key: 'actualNetWeightKg', label: '总净重(kg)', minWidth: 118, slotName: 'actualNetWeightKg' },
{ key: 'destinationType', label: '目的仓类型', minWidth: 112, slotName: 'destinationType' },
{ key: 'destinationName', label: '目的仓', minWidth: 132, slotName: 'destinationName' },
{ key: 'measureCompletedAt', label: '测量完成时间', minWidth: 164, slotName: 'measureCompletedAt' },
{ key: 'inboundedAt', label: '入库完成时间', minWidth: 164, slotName: 'inboundedAt' },
{ key: 'expressCompanyName', label: '快递公司', minWidth: 120, slotName: 'expressCompanyName' },
{ key: 'expressTrackingNo', label: '快递单号', minWidth: 150, slotName: 'expressTrackingNo' },
{ key: 'createdAt', label: '创建日期', minWidth: 164, slotName: 'createdAt' },
]
const selectedCustomerName = computed(() => {
const customer = customers.value.find((item) => String(item.id) === selectedCustomerId.value)
return customer?.legalName?.trim() || customer?.tenantName?.trim() || '全部客户'
})
const listEmptyText = '暂无符合条件的入库单'
function queryValue(value: string) { return value.trim() || undefined }
function handleTabKeydown(event: KeyboardEvent, currentIndex: number) {
const lastIndex = tabs.length - 1
let nextIndex = currentIndex
if (event.key === 'ArrowRight') nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1
else if (event.key === 'ArrowLeft') nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = lastIndex
else return
event.preventDefault()
tab.value = tabs[nextIndex].value
const tabButtons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
tabButtons?.[nextIndex]?.focus()
}
function dateParam(value: string | undefined, end = false) {
if (!value) return undefined
return `${value}T${end ? '23:59:59' : '00:00:00'}`
}
async function loadWarehouseOptions() {
const requestId = ++warehouseRequestId
warehouseOptions.value = []
warehousesUnavailable.value = false
if (!selectedCustomerId.value) {
warehousesLoading.value = false
return
}
warehousesLoading.value = true
try {
const response = await listPlatformWarehouses(selectedCustomerId.value, 1, 200)
if (disposed || requestId !== warehouseRequestId) return
warehouseOptions.value = response.records.map((warehouse) => ({
label: `${warehouse.warehouseName} · ${warehouse.warehouseCode}`,
value: String(warehouse.id),
}))
} catch {
if (disposed || requestId !== warehouseRequestId) return
warehousesUnavailable.value = true
} finally {
if (!disposed && requestId === warehouseRequestId) warehousesLoading.value = false
}
}
async function loadCustomers() {
const requestId = ++customerRequestId
customersLoading.value = true
customerLoadError.value = null
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const response = await listPlatformTenants({ page: currentPage, size: 200 })
records.push(...response.records)
pages = Math.max(1, response.pages)
currentPage += 1
} while (currentPage <= pages)
if (disposed || requestId !== customerRequestId) return
customers.value = records
} catch (error) {
if (disposed || requestId !== customerRequestId) return
customers.value = []
customerLoadError.value = getApiErrorDetails(error, '客户列表加载失败,暂时无法按所属企业筛选;入库单仍按全部客户查询')
} finally {
if (!disposed && requestId === customerRequestId) customersLoading.value = false
}
}
async function loadInbounds() {
const requestId = ++loadRequestId
loading.value = true
loadError.value = null
const range = filters.value.expectedArrivalRange
try {
const response = await listPlatformInbounds(selectedCustomerId.value || undefined, {
page: page.value,
size: pageSize.value,
tab: tab.value || undefined,
inboundCode: queryValue(filters.value.inboundCode),
orderNo: queryValue(filters.value.orderNo),
warehouseId: queryValue(filters.value.warehouseId),
deliveryWarehouseId: queryValue(filters.value.deliveryWarehouseId),
processStatus: queryValue(filters.value.processStatus),
orderStatus: queryValue(filters.value.orderStatus),
measureReviewStatus: queryValue(filters.value.measureReviewStatus),
customerName: queryValue(filters.value.customerName),
destinationCode: queryValue(filters.value.destinationCode),
feeStatus: queryValue(filters.value.feeStatus),
feeReviewStatus: queryValue(filters.value.feeReviewStatus),
holdFlag: filters.value.holdFlag === '' ? undefined : filters.value.holdFlag === 'true',
expectedArrivalFrom: dateParam(range?.[0]),
expectedArrivalTo: dateParam(range?.[1], true),
})
if (disposed || requestId !== loadRequestId) return
result.value = { records: response.records, total: response.total }
} catch (error) {
if (disposed || requestId !== loadRequestId) return
loadError.value = getApiErrorDetails(error, '入库单加载失败')
} finally {
if (!disposed && requestId === loadRequestId) loading.value = false
}
}
function applyFilters() {
if (page.value === 1) void loadInbounds()
else page.value = 1
}
function resetDetail() {
detailRequestId += 1
detail.value = null
detailTarget.value = null
detailError.value = null
detailLoading.value = false
}
async function openDetail(row: InboundOrderSummary) {
const requestId = ++detailRequestId
detailOpen.value = true
detailTarget.value = row
detailLoading.value = true
detailError.value = null
detail.value = null
try {
const response = await getPlatformInbound(recordCustomerId(row), row.id)
if (disposed || requestId !== detailRequestId) return
detail.value = response
} catch (error) {
if (disposed || requestId !== detailRequestId) return
detailError.value = getApiErrorDetails(error, '入库单详情加载失败')
} finally {
if (!disposed && requestId === detailRequestId) detailLoading.value = false
}
}
function closeDetail() { resetDetail() }
function recordCustomerId(row: InboundOrderSummary) { return row.customerId || row.companyId || selectedCustomerId.value }
function openWorkspace(row: InboundOrderSummary) {
const customerId = recordCustomerId(row)
void router.push({ name: 'inbound-order-detail', params: { inboundId: row.id }, query: customerId ? { tenantId: customerId } : undefined })
}
function formatNumber(value: number | null, digits = 3) { return value === null ? '—' : value.toFixed(digits) }
function formatDate(value: string | null) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function text(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function statusLabel(status: string) {
return ({ WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '测量驳回', WAIT_PRICING: '待计费', FEE_CONFIRMING: '待发送费用', INBOUNDING: '入库处理中', INBOUNDED: '已入库', VOIDED: '已作废' } as Record<string, string>)[status] || status || '未知状态'
}
function statusType(status: string) {
if (status === 'INBOUNDED') return 'success'
if (status === 'MEASURE_REJECTED' || status === 'VOIDED') return 'danger'
if (status === 'MEASURING' || status === 'INBOUNDING') return 'info'
return 'warning'
}
function codeLabel(status: string | null | undefined, labels: Record<string, string>) { return status ? labels[status] || status : '—' }
const measureReviewLabels: Record<string, string> = { PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' }
const feeStatusLabels: Record<string, string> = { UNPRICED: '未计费', CALCULATED: '已试算', CONFIRMED: '已确认', SENT: '已发送', VOIDED: '已作废' }
const loadingStatusLabels: Record<string, string> = { NOT_LOADED: '未配载', LOADING: '配载中', LOADED: '已配载' }
const taxModeLabels: Record<string, string> = { TAX_INCLUDED: '包税', TAX_EXCLUDED: '不包税', DDP: '包税', DDU: '不包税' }
const containerModeLabels: Record<string, string> = { FCL: '整柜', LCL: '拼箱' }
function resetFilters() {
selectedCustomerId.value = ''
showAdvancedFilters.value = false
filters.value = { ...filters.value, customerId: '', warehouseId: '', deliveryWarehouseId: '' }
warehouseOptions.value = []
warehousesUnavailable.value = false
resetDetail()
detailOpen.value = false
applyFilters()
}
function toggleAdvancedFilters() {
if (!showAdvancedFilters.value) {
showAdvancedFilters.value = true
return
}
showAdvancedFilters.value = false
filters.value = {
...filters.value,
orderStatus: '',
feeReviewStatus: '',
customerName: '',
destinationCode: '',
holdFlag: '',
}
applyFilters()
}
watch(() => filters.value.customerId, (customerId) => {
if (customerId === selectedCustomerId.value) return
selectedCustomerId.value = customerId
page.value = 1
tab.value = ''
filters.value = { ...filters.value, warehouseId: '', deliveryWarehouseId: '' }
resetDetail()
detailOpen.value = false
void loadWarehouseOptions()
void loadInbounds()
})
watch(tab, () => { page.value = 1; void loadInbounds() })
watch([page, pageSize], () => { void loadInbounds() })
onMounted(() => { void loadCustomers(); void loadInbounds() })
onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId += 1; customerRequestId += 1; warehouseRequestId += 1 })
</script>
<template>
<div class="business-page inbound-orders-page">
<QueryToolbar v-model="filters" class="surface-panel inbound-query-toolbar" ariaLabel="入库单筛选" :fields="queryFields" :search-on-reset="false" @search="applyFilters" @reset="resetFilters">
<template #actions>
<el-button :aria-expanded="showAdvancedFilters" @click="toggleAdvancedFilters">
<SlidersHorizontal :size="16" />
{{ showAdvancedFilters ? '收起筛选' : '更多筛选' }}
<ChevronUp v-if="showAdvancedFilters" :size="15" />
<ChevronDown v-else :size="15" />
</el-button>
</template>
</QueryToolbar>
<el-alert v-if="customerLoadError" type="warning" :closable="false" show-icon role="alert">
<template #title><div class="api-error-alert-title"><span>{{ customerLoadError.message }}</span><ApiErrorMeta :details="customerLoadError" /></div></template>
<el-button link type="warning" :loading="customersLoading" @click="loadCustomers">重新加载客户列表</el-button>
</el-alert>
<LoadErrorState v-if="loadError" class="inbound-load-error" :message="loadError.message" title="入库单暂时无法加载" @retry="loadInbounds"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section v-else class="surface-panel table-panel" :aria-busy="loading">
<header class="table-toolbar">
<nav class="inbound-tabs" role="tablist" aria-label="入库单处理状态">
<button v-for="(item, index) in tabs" :key="item.value || 'all'" type="button" role="tab" class="inbound-tab" :class="{ 'is-active': tab === item.value }" :aria-selected="tab === item.value" :tabindex="tab === item.value ? 0 : -1" :title="item.description" @click="tab = item.value" @keydown="handleTabKeydown($event, index)">{{ item.label }}</button>
</nav>
<div class="table-toolbar-actions">
<span class="result-summary">共 {{ result.total }} 条 · 所属企业 {{ selectedCustomerName }}</span>
<el-button :loading="loading" @click="loadInbounds"><RefreshCw :size="16" />刷新</el-button>
</div>
</header>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openWorkspace(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button link @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div></template>
<template #inbound="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
<template #orderStatus="{ row }"><div class="status-stack"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag><span>订单:{{ text((row as InboundOrderSummary).orderStatusSnapshot) }}</span></div></template>
<template #measureReviewStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).measureReviewStatus, measureReviewLabels) }}</template>
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
<template #feeStatus="{ row }"><div class="status-stack"><span>{{ codeLabel((row as InboundOrderSummary).feeStatus, feeStatusLabels) }}</span><small v-if="(row as InboundOrderSummary).feeReviewStatus">审核:{{ text((row as InboundOrderSummary).feeReviewStatus) }}</small></div></template>
<template #customerName="{ row }">{{ text((row as InboundOrderSummary).customerName) }}</template>
<template #warehouseName="{ row }">{{ text((row as InboundOrderSummary).warehouseName) }}</template>
<template #deliveryWarehouseName="{ row }">{{ text((row as InboundOrderSummary).deliveryWarehouseName) }}</template>
<template #loadingStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).loadingStatus, loadingStatusLabels) }}</template>
<template #holdFlag="{ row }"><el-tag :type="(row as InboundOrderSummary).holdFlag ? 'danger' : 'info'" effect="plain">{{ (row as InboundOrderSummary).holdFlag ? '是' : '否' }}</el-tag></template>
<template #currentHoldReason="{ row }">{{ text((row as InboundOrderSummary).currentHoldReason) }}</template>
<template #containerNo="{ row }">{{ text((row as InboundOrderSummary).containerNo) }}</template>
<template #cabinetNo="{ row }">{{ text((row as InboundOrderSummary).cabinetNo) }}</template>
<template #companyName="{ row }">{{ text((row as InboundOrderSummary).companyName) }}</template>
<template #businessType="{ row }">{{ text((row as InboundOrderSummary).businessType) }}</template>
<template #channelName="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).channelName) }}</strong><span>{{ text((row as InboundOrderSummary).channelCode) }}</span></div></template>
<template #taxMode="{ row }">{{ codeLabel((row as InboundOrderSummary).taxMode, taxModeLabels) }}</template>
<template #containerMode="{ row }">{{ codeLabel((row as InboundOrderSummary).containerMode, containerModeLabels) }}</template>
<template #customsInspection="{ row }">{{ (row as InboundOrderSummary).customsInspection === null ? '—' : (row as InboundOrderSummary).customsInspection ? '是' : '否' }}</template>
<template #sailingAt="{ row }">{{ formatDate((row as InboundOrderSummary).sailingAt) }}</template>
<template #portArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).portArrivalAt) }}</template>
<template #totalPackageCount="{ row }">{{ text((row as InboundOrderSummary).totalPackageCount) }}</template>
<template #actualGrossWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).actualGrossWeightKg) }}</template>
<template #chargeableWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).chargeableWeightKg) }}</template>
<template #actualVolumeCbm="{ row }">{{ formatNumber((row as InboundOrderSummary).actualVolumeCbm) }}</template>
<template #chargeableVolumeCbm="{ row }">{{ formatNumber((row as InboundOrderSummary).chargeableVolumeCbm) }}</template>
<template #palletCount="{ row }">{{ text((row as InboundOrderSummary).palletCount) }}</template>
<template #locationCodes="{ row }">{{ text((row as InboundOrderSummary).locationCodes) }}</template>
<template #volumeWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).volumeWeightKg) }}</template>
<template #actualNetWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).actualNetWeightKg) }}</template>
<template #destinationType="{ row }">{{ text((row as InboundOrderSummary).destinationType) }}</template>
<template #destinationName="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).destinationName) }}</strong><span>{{ text((row as InboundOrderSummary).destinationCode) }}</span></div></template>
<template #measureCompletedAt="{ row }">{{ formatDate((row as InboundOrderSummary).measureCompletedAt) }}</template>
<template #inboundedAt="{ row }">{{ formatDate((row as InboundOrderSummary).inboundedAt) }}</template>
<template #expressCompanyName="{ row }">{{ text((row as InboundOrderSummary).expressCompanyName) }}</template>
<template #expressTrackingNo="{ row }">{{ text((row as InboundOrderSummary).expressTrackingNo) }}</template>
<template #createdAt="{ row }">{{ formatDate((row as InboundOrderSummary).createdAt) }}</template>
<template #empty><el-empty :description="listEmptyText" /></template>
</DataTable>
</section>
<AppDrawer v-model="detailOpen" :title="detail ? `入库单详情 · ${detail.summary.inboundCode || detail.summary.id}` : '入库单详情'" size="min(720px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
<section v-if="detailError" class="detail-error" role="alert"><strong>入库单详情暂时无法加载</strong><p>{{ detailError.message }}</p><ApiErrorMeta :details="detailError" /><el-button type="primary" :disabled="!detailTarget" @click="detailTarget && openDetail(detailTarget)">重新加载</el-button></section>
<div v-else-if="detail" class="detail-content"><InboundSummaryPanel :summary="detail.summary" /><InboundHistoryPanel :pallet-locations="detail.palletLocations" :files="detail.files" :internal-notes="detail.internalNotes" :status-history="detail.statusHistory" :public-remark="detail.publicRemark" /></div>
</AppDrawer>
</div>
</template>
<style scoped>
.inbound-orders-page { min-width: 0; }
.inbound-tabs { display: flex; max-width: 100%; min-width: 0; overflow-x: auto; padding: 4px; border: 1px solid var(--cc-border); border-radius: 8px; background: #f7f9fc; scrollbar-width: none; }
.inbound-tabs::-webkit-scrollbar { display: none; }
.inbound-tab { min-height: 44px; flex: 0 0 auto; padding: 8px 18px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--cc-muted); cursor: pointer; font-size: 13px; line-height: 1.4; white-space: nowrap; transition: background-color .16s ease, border-color .16s ease, color .16s ease, box-shadow .16s ease; }
.inbound-tab:hover { background: #fff; color: var(--cc-primary); }
.inbound-tab:focus-visible { outline: 3px solid rgba(102, 126, 234, .2); outline-offset: 1px; }
.inbound-tab.is-active { border-color: #d8e0f0; background: #fff; color: var(--cc-primary); box-shadow: 0 1px 3px rgba(16, 24, 40, .08); font-weight: 650; }
.inbound-load-error { margin-top: 12px; }
.inbound-query-toolbar { border-radius: 8px; }
.row-actions { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
.row-actions :deep(.el-button) { margin-left: 0; }
.table-toolbar { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; min-height: 68px; padding: 10px 14px; border-bottom: 1px solid var(--cc-border); }
.table-toolbar-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 14px; }
.result-summary { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.table-toolbar :deep(.el-button) { min-height: 40px; margin-left: 0; }
.primary-cell, .status-stack { display: grid; min-width: 0; justify-items: start; gap: 4px; }
.primary-cell strong, .primary-cell span, .status-stack span, .status-stack small { overflow-wrap: anywhere; }
.primary-cell span, .status-stack span, .status-stack small { color: var(--cc-muted); font-size: 12px; }
.detail-content { display: grid; gap: 24px; min-width: 0; }
.detail-error { display: flex; min-height: 240px; align-items: flex-start; justify-content: center; flex-direction: column; }
.detail-error strong { color: var(--cc-danger); font-size: 15px; }
.detail-error p { margin: 7px 0; color: var(--cc-text); font-size: 13px; }
.detail-error :deep(.el-button) { margin-top: 14px; }
@media (max-width: 900px) { .table-toolbar { align-items: stretch; flex-direction: column; } .inbound-tabs { width: 100%; } .table-toolbar-actions { justify-content: space-between; } }
@media (max-width: 720px) { .inbound-tab { padding-inline: 14px; } .table-toolbar { padding: 10px; } .table-toolbar-actions { gap: 10px; } .result-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .table-toolbar :deep(.el-button) { min-height: 44px; } }
</style>
@@ -0,0 +1,94 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import DataTable from '@/shared/components/DataTable.vue'
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory } from './api'
import MeasurementReviewPanel from './MeasurementReviewPanel.vue'
vi.mock('./api', () => ({
approveMeasurement: vi.fn(),
getMeasurementReviewDetail: vi.fn(),
listMeasurementReviewHistory: vi.fn(),
rejectMeasurement: vi.fn(),
releaseRejectedMeasurement: vi.fn(),
}))
const detail = {
inboundId: '90001', inboundCode: 'RK90001', companyName: '柏悦客户', channelCode: 'SEA', channelName: '海运',
processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: 'STANDARD', reviewReason: null, reviewerId: null, reviewerName: null,
measurementId: '70001', sourceType: 'DEVICE', measurementStatus: 'SUBMITTED', version: 4,
originalDocumentNo: 'ORD-001', originalGrossWeightKg: 100, originalVolumeCbm: 2,
actualPackageCount: 10, actualGrossWeightKg: 110, actualVolumeCbm: 2.2, volumeWeightKg: 367.4, actualGoodsName: '测试货物',
items: [{ id: '1', itemNo: '001', boxSpecification: '60×40×30', packageCount: 10, lengthCm: 60, widthCm: 40, heightCm: 30, girthCm: 240, unitWeightKg: 11, unitVolumeCbm: 0.072, totalWeightKg: 110, totalVolumeCbm: 0.72, volumeWeightKg: 120.24, intelligentImageFileId: '30001', overLimitWarning: '周长超过渠道限制,请人工核验尺寸后再作出审核决定', outerLengthCm: 61, outerWidthCm: 41, outerHeightCm: 31, remark: '外箱存在轻微变形' }],
weightResults: [{ id: '2', fileId: 'file-1', ocrProvider: 'OCR', ocrRawText: '设备屏幕原始识别内容 110.5 KG,包含一段需要自动换行展示的较长文本', ocrWeightValue: 110.5, ocrWeightUnit: 'KG', confidence: 0.98, adjustedWeightValue: 110, adjustedWeightUnit: 'KG', finalWeightKg: 110, recognitionStatus: 'CONFIRMED', adjustmentReason: '去除托盘重量后采用净值', adjustedBy: '20001', adjustedAt: '2026-08-04T09:58:00', confirmedBy: '20002', confirmedAt: '2026-08-04T10:00:00', version: 1 }],
}
enableAutoUnmount(afterEach)
describe('MeasurementReviewPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = ['firstleg:inbound:measurement-review', 'firstleg:inbound:measurement-review-history', 'firstleg:inbound:exception-release']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(getMeasurementReviewDetail).mockResolvedValue(detail)
vi.mocked(listMeasurementReviewHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
})
it('loads and compares original, device and OCR data without exposing a normal fee action', async () => {
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('原单申报')
expect(wrapper.text()).toContain('设备实测')
expect(wrapper.text()).toContain('称重 OCR')
expect(wrapper.text()).toContain('外箱长 × 宽 × 高(cm)')
expect(wrapper.text()).toContain('周长超过渠道限制')
expect(wrapper.text()).toContain('OCR 原文:设备屏幕原始识别内容')
expect(wrapper.text()).toContain('调整原因:去除托盘重量后采用净值')
expect(wrapper.findComponent(DataTable).exists()).toBe(true)
expect(wrapper.text()).not.toContain('租户')
expect(wrapper.text()).toContain('通过并完成入库')
expect(wrapper.text()).not.toContain('确认费用')
expect(getMeasurementReviewDetail).toHaveBeenCalledWith('10001', '90001')
})
it('shows the rejected release action only when the permission and rejected state exist', async () => {
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, processStatus: 'MEASURE_REJECTED', measureReviewStatus: 'REJECTED', reviewReason: '重量图片不清晰' })
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('异常放行')
expect(wrapper.text()).toContain('重量图片不清晰')
const auth = useAuthStore()
auth.permissions = ['firstleg:inbound:measurement-review-history']
useAccessPreviewStore().initializeAfterLogin()
await wrapper.vm.$nextTick()
expect(wrapper.findAll('button').some((button) => button.text().includes('异常放行'))).toBe(false)
})
it('emits changed after a successful review write', async () => {
vi.mocked(approveMeasurement).mockResolvedValue({ feeId: '1', feeNo: 'FEE-1', feeVersion: 1, feeStatus: 'SENT', inboundId: '90001', totalAmount: 100, currency: 'CNY', nextProcessStatus: 'INBOUNDED' })
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const buttons = wrapper.findAll('button')
const approve = buttons.find((button) => button.text().includes('通过并完成入库'))
expect(approve).toBeDefined()
await approve?.trigger('click')
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
expect.stringContaining('自动试算并发布费用'),
'确认测量审核通过',
expect.objectContaining({ confirmButtonText: '通过并完成入库' }),
)
expect(approveMeasurement).toHaveBeenCalledWith('10001', '90001', { measurementId: '70001', expectedVersion: 4 })
expect(wrapper.emitted('changed')).toHaveLength(1)
})
})
@@ -0,0 +1,342 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, CircleAlert, History, RotateCcw, ShieldAlert, X } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import {
approveMeasurement,
getMeasurementReviewDetail,
listMeasurementReviewHistory,
rejectMeasurement,
releaseRejectedMeasurement,
} from './api'
import type { MeasurementReviewDetail, MeasurementReviewItem, MeasurementReviewRecord } from './types'
const props = defineProps<{
customerId?: string
/** @deprecated 仅用于兼容尚未完成 customerId 映射的上层集成。 */
tenantId?: string
inboundId: string
}>()
const emit = defineEmits<{
changed: []
}>()
const access = useAccessPreviewStore()
const detail = ref<MeasurementReviewDetail | null>(null)
const history = ref<MeasurementReviewRecord[]>([])
const loading = ref(false)
const historyLoading = ref(false)
const actionLoading = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const historyError = ref<ApiErrorDetails | null>(null)
const reasonDialog = ref<'reject' | 'release' | null>(null)
const reason = ref('')
const requestSerial = ref(0)
const reasonDialogOpen = computed({
get: () => reasonDialog.value !== null,
set: (open: boolean) => { if (!open) reasonDialog.value = null },
})
const canReview = computed(() => access.can('firstleg:inbound:measurement-review'))
const canRelease = computed(() => access.can('firstleg:inbound:exception-release'))
const customerId = computed(() => props.customerId || props.tenantId || '')
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
const confirmedWeights = computed(() => detail.value?.weightResults.filter((item) => item.confirmedAt && item.finalWeightKg !== null).length ?? 0)
const reviewStatusLabel = computed(() => {
if (isPending.value) return '待审核'
if (isRejected.value) return '已驳回'
if (detail.value?.measureReviewStatus === 'APPROVED') return '已通过'
return detail.value?.measureReviewStatus || '未提交'
})
const measurementColumns: DataTableColumn[] = [
{ key: 'boxSpecification', label: '箱规', minWidth: 130, slotName: 'boxSpecification' },
{ key: 'packageCount', label: '箱数', width: 80, slotName: 'packageCount' },
{ key: 'dimensions', label: '长 × 宽 × 高(cm)', minWidth: 170, slotName: 'dimensions' },
{ key: 'outerDimensions', label: '外箱长 × 宽 × 高(cm)', minWidth: 190, slotName: 'outerDimensions' },
{ key: 'girthCm', label: '周长(cm)', minWidth: 105, slotName: 'girthCm' },
{ key: 'unitWeightKg', label: '每箱毛重(kg)', minWidth: 125, slotName: 'unitWeightKg' },
{ key: 'unitVolumeCbm', label: '每箱体积(m³)', minWidth: 125, slotName: 'unitVolumeCbm' },
{ key: 'totalWeightKg', label: '总毛重(kg)', minWidth: 110, slotName: 'totalWeightKg' },
{ key: 'totalVolumeCbm', label: '总体积(m³)', minWidth: 110, slotName: 'totalVolumeCbm' },
{ key: 'volumeWeightKg', label: '材积重(kg)', minWidth: 110, slotName: 'volumeWeightKg' },
{ key: 'note', label: '超限提示 / 备注', minWidth: 200, slotName: 'note' },
]
function formatNumber(value: number | null | undefined, unit = '') {
if (value === null || value === undefined || Number.isNaN(value)) return '-'
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })}${unit}`
}
function formatDate(value: string | null | undefined) {
return value ? value.replace('T', ' ') : '-'
}
function resultLabel(value: string | null | undefined) {
if (value === 'APPROVED') return '审核通过'
if (value === 'REJECTED') return '审核驳回'
return value || '未知结果'
}
function isConflict(error: unknown) {
const details = getApiErrorDetails(error)
return details.code === 'A0409' || details.code === '409' || /并发|版本|已被更新|状态已变化/.test(details.message)
}
async function loadReview() {
const serial = ++requestSerial.value
loadError.value = null
loading.value = true
try {
const result = await getMeasurementReviewDetail(customerId.value, props.inboundId)
if (serial === requestSerial.value) detail.value = result
} catch (error) {
if (serial === requestSerial.value) {
detail.value = null
loadError.value = getApiErrorDetails(error, '测量审核数据加载失败')
}
} finally {
if (serial === requestSerial.value) loading.value = false
}
}
async function loadHistory() {
historyError.value = null
historyLoading.value = true
try {
const result = await listMeasurementReviewHistory(customerId.value, props.inboundId)
history.value = result.records
} catch (error) {
history.value = []
historyError.value = getApiErrorDetails(error, '审核历史加载失败')
} finally {
historyLoading.value = false
}
}
async function reload() {
await Promise.all([loadReview(), loadHistory()])
}
async function confirmApprove() {
if (!detail.value || !isPending.value || !canReview.value) return
try {
await ElMessageBox.confirm(
'审核通过后,系统会自动试算并发布费用,同时完成托盘库位占用和入库。客户无需再次确认费用。是否继续?',
'确认测量审核通过',
{ confirmButtonText: '通过并完成入库', cancelButtonText: '返回', type: 'warning' },
)
} catch {
return
}
actionLoading.value = 'approve'
try {
await approveMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.version,
})
ElMessage.success('审核已通过,费用发布和入库正在完成')
await reload()
emit('changed')
} catch (error) {
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重新审核' : getApiErrorDetails(error, '审核通过失败').message)
if (isConflict(error)) await reload()
} finally {
actionLoading.value = ''
}
}
function openReasonDialog(kind: 'reject' | 'release') {
reason.value = ''
reasonDialog.value = kind
}
async function submitReason() {
if (!detail.value || !reasonDialog.value) return
const value = reason.value.trim()
if (!value) {
ElMessage.warning(reasonDialog.value === 'reject' ? '请填写驳回原因' : '请填写异常放行原因')
return
}
const kind = reasonDialog.value
actionLoading.value = kind
try {
if (kind === 'reject') {
await rejectMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.version,
reason: value,
})
ElMessage.success('测量已驳回,已退回测量修正')
} else {
await releaseRejectedMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.version,
reason: value,
})
ElMessage.success('异常放行已提交,后续计费入库将继续处理')
}
reasonDialog.value = null
await reload()
emit('changed')
} catch (error) {
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重试' : getApiErrorDetails(error, kind === 'reject' ? '测量驳回失败' : '异常放行失败').message)
if (isConflict(error)) await reload()
} finally {
actionLoading.value = ''
}
}
watch(() => [customerId.value, props.inboundId], () => {
if (customerId.value && props.inboundId) void reload()
}, { immediate: true })
onMounted(() => {
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
})
</script>
<template>
<section class="review-panel" aria-labelledby="measurement-review-title">
<header class="review-header">
<div>
<h2 id="measurement-review-title">测量审核</h2>
<p>核对原单、设备实测和称重 OCR 后,再决定是否进入自动计费入库。</p>
</div>
<div class="review-header-status">
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
<el-button text :loading="loading || historyLoading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
</div>
</header>
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title><div class="review-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
<el-button link type="danger" @click="reload">重新加载</el-button>
</el-alert>
<el-skeleton v-if="loading && !detail" :rows="8" animated />
<template v-else-if="detail">
<div class="comparison-grid">
<article class="comparison-card">
<span class="comparison-label">原单申报</span>
<strong>{{ detail.originalDocumentNo || detail.inboundCode || '-' }}</strong>
<dl>
<div><dt>申报毛重</dt><dd>{{ formatNumber(detail.originalGrossWeightKg, ' kg') }}</dd></div>
<div><dt>申报体积</dt><dd>{{ formatNumber(detail.originalVolumeCbm, ' CBM') }}</dd></div>
</dl>
</article>
<article class="comparison-card">
<span class="comparison-label">设备实测</span>
<strong>{{ formatNumber(detail.actualPackageCount, ' 箱') }}</strong>
<dl>
<div><dt>实测体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, ' CBM') }}</dd></div>
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg, ' kg') }}</dd></div>
</dl>
</article>
<article class="comparison-card">
<span class="comparison-label">称重 OCR</span>
<strong>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</strong>
<dl>
<div><dt>已确认图片</dt><dd>{{ confirmedWeights }} / {{ detail.weightResults.length }}</dd></div>
<div><dt>测量来源</dt><dd>{{ detail.sourceType || '-' }}</dd></div>
</dl>
</article>
</div>
<div v-if="detail.reviewReason" class="review-reason">
<CircleAlert :size="17" /><span><strong>最近审核说明:</strong>{{ detail.reviewReason }}</span>
</div>
<div class="review-section">
<div class="section-heading"><div><h3>三组数据对比</h3><p>{{ detail.companyName || '-' }} · {{ detail.channelName || detail.channelCode || '-' }}</p></div><span class="section-count">{{ detail.items.length }} 条箱规</span></div>
<DataTable :data="detail.items" :columns="measurementColumns" empty-text="暂无设备测量明细" border stripe>
<template #boxSpecification="{ row }">{{ (row as MeasurementReviewItem).boxSpecification || (row as MeasurementReviewItem).itemNo || '-' }}</template>
<template #packageCount="{ row }">{{ (row as MeasurementReviewItem).packageCount ?? '-' }}</template>
<template #dimensions="{ row }">{{ [(row as MeasurementReviewItem).lengthCm, (row as MeasurementReviewItem).widthCm, (row as MeasurementReviewItem).heightCm].map((value) => formatNumber(value)).join(' × ') }}</template>
<template #outerDimensions="{ row }">{{ [(row as MeasurementReviewItem).outerLengthCm, (row as MeasurementReviewItem).outerWidthCm, (row as MeasurementReviewItem).outerHeightCm].map((value) => formatNumber(value)).join(' × ') }}</template>
<template #girthCm="{ row }">{{ formatNumber((row as MeasurementReviewItem).girthCm) }}</template>
<template #unitWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).unitWeightKg) }}</template>
<template #unitVolumeCbm="{ row }">{{ formatNumber((row as MeasurementReviewItem).unitVolumeCbm) }}</template>
<template #totalWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).totalWeightKg) }}</template>
<template #totalVolumeCbm="{ row }">{{ formatNumber((row as MeasurementReviewItem).totalVolumeCbm) }}</template>
<template #volumeWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).volumeWeightKg) }}</template>
<template #note="{ row }"><div class="measurement-note"><strong v-if="(row as MeasurementReviewItem).overLimitWarning">{{ (row as MeasurementReviewItem).overLimitWarning }}</strong><span>{{ (row as MeasurementReviewItem).remark || ((row as MeasurementReviewItem).overLimitWarning ? '' : '-') }}</span></div></template>
<template #empty><el-empty description="暂无设备测量明细" :image-size="48" /></template>
</DataTable>
</div>
<div class="review-section">
<div class="section-heading"><div><h3>称重图片识别结果</h3><p>原始 OCR 值不可覆盖,只有已确认且有效的结果会计入实际重量。</p></div><span class="section-count">{{ detail.weightResults.length }} 张</span></div>
<div class="weight-list">
<div v-for="item in detail.weightResults" :key="item.id" class="weight-row">
<div><strong>{{ item.fileId || '未关联文件' }}</strong><span>{{ item.ocrProvider || 'OCR' }} · {{ item.recognitionStatus || '待确认' }}</span><p v-if="item.ocrRawText">OCR 原文:{{ item.ocrRawText }}</p><p v-if="item.adjustmentReason">调整原因:{{ item.adjustmentReason }}</p></div>
<div><span>原始</span><strong>{{ formatNumber(item.ocrWeightValue, ` ${item.ocrWeightUnit || ''}`) }}</strong></div>
<div><span>调整后</span><strong>{{ formatNumber(item.adjustedWeightValue, ` ${item.adjustedWeightUnit || ''}`) }}</strong></div>
<div><span>最终</span><strong>{{ formatNumber(item.finalWeightKg, ' kg') }}</strong></div>
<div><span>置信度</span><strong>{{ item.confidence === null ? '-' : `${Math.round(item.confidence * 100)}%` }}</strong></div>
</div>
<el-empty v-if="!detail.weightResults.length" description="暂无称重图片识别结果" :image-size="48" />
</div>
</div>
<div class="review-actions" :class="{ 'review-actions--disabled': !isPending && !isRejected }">
<div><strong>审核结论</strong><span v-if="isPending">通过后将自动完成计费、费用发布和入库。</span><span v-else-if="isRejected">当前测量已驳回,可修正后重新提交,或按权限申请异常放行。</span><span v-else>当前记录不在可操作的测量审核状态。</span></div>
<div class="action-buttons">
<el-button v-if="isPending && canReview" type="danger" plain :loading="actionLoading === 'reject'" @click="openReasonDialog('reject')"><X :size="16" />驳回</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并完成入库</el-button>
<el-button v-if="isRejected && canRelease" type="warning" plain :loading="actionLoading === 'release'" @click="openReasonDialog('release')"><ShieldAlert :size="16" />异常放行</el-button>
</div>
</div>
<div class="review-section history-section">
<div class="section-heading"><div><h3><History :size="17" />审核历史</h3><p>每次审核均保留操作人、原因和放行开关快照。</p></div></div>
<el-alert v-if="historyError" type="warning" :closable="false" show-icon><template #title><div class="review-alert-title"><span>{{ historyError.message }}</span><ApiErrorMeta :details="historyError" /></div></template><el-button link type="warning" @click="loadHistory">重试</el-button></el-alert>
<el-skeleton v-else-if="historyLoading" :rows="3" animated />
<el-empty v-else-if="!history.length" description="暂无审核历史" :image-size="48" />
<ol v-else class="history-list">
<li v-for="item in history" :key="item.id">
<div class="history-marker" :class="{ 'history-marker--success': item.reviewResult === 'APPROVED', 'history-marker--danger': item.reviewResult === 'REJECTED' }" />
<div class="history-content"><div><strong>{{ resultLabel(item.reviewResult) }}{{ item.exceptionRelease ? ' · 异常放行' : '' }}</strong><time>{{ formatDate(item.reviewedAt) }}</time></div><p>{{ item.releaseReason || item.reviewReason || '未填写说明' }}</p><span>{{ item.reviewerName || '系统' }} · {{ item.allowRejectedConfig ? '放行开关已开启' : '放行开关未开启' }}</span></div>
</li>
</ol>
</div>
</template>
<el-dialog v-model="reasonDialogOpen" :title="reasonDialog === 'reject' ? '填写驳回原因' : '填写异常放行原因'" width="520px" destroy-on-close>
<el-alert v-if="reasonDialog === 'release'" title="异常放行不会抹去驳回记录;请说明为什么允许该测量继续进入自动计费入库。" type="warning" :closable="false" show-icon />
<el-input v-model="reason" type="textarea" :rows="5" maxlength="1000" show-word-limit :placeholder="reasonDialog === 'reject' ? '请说明需要修正的测量问题' : '请填写异常放行的业务原因'" />
<template #footer><el-button :disabled="Boolean(actionLoading)" @click="reasonDialog = null">取消</el-button><el-button :type="reasonDialog === 'reject' ? 'danger' : 'warning'" :loading="Boolean(actionLoading)" @click="submitReason">确认{{ reasonDialog === 'reject' ? '驳回' : '放行' }}</el-button></template>
</el-dialog>
</section>
</template>
<style scoped>
.review-panel { display: grid; gap: 20px; color: var(--cc-text); }
.review-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; padding-bottom: 4px; }
.review-header h2 { margin: 0 0 5px; font-size: 22px; line-height: 1.25; }
.review-header p, .section-heading p { margin: 0; color: var(--cc-muted); font-size: 13px; line-height: 1.55; }
.review-header-status { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
.review-alert-title { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.comparison-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.comparison-card { min-width: 0; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface); }
.comparison-card strong { display: block; margin: 8px 0 12px; font-size: 21px; line-height: 1.2; overflow-wrap: anywhere; }
.comparison-label { color: var(--cc-muted); font-size: 12px; font-weight: 700; }
dl { display: grid; gap: 7px; margin: 0; }.comparison-card dl div { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; }.comparison-card dt { color: var(--cc-muted); }.comparison-card dd { margin: 0; font-weight: 600; text-align: right; }
.review-reason { display: flex; gap: 8px; align-items: flex-start; padding: 12px 14px; border: 1px solid #f3d19e; border-radius: 8px; background: #fdf6ec; color: #8a5a00; font-size: 13px; line-height: 1.55; }
.review-section { display: grid; gap: 13px; padding-top: 2px; }.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }.section-heading h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 4px; font-size: 16px; }.section-count { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.measurement-note { overflow-wrap: anywhere; }.measurement-note strong, .measurement-note span { display: block; }.measurement-note strong { color: var(--el-color-warning-dark-2); }.measurement-note strong + span { margin-top: 4px; color: var(--cc-muted); }
.weight-list { display: grid; gap: 8px; }.weight-row { display: grid; grid-template-columns: minmax(190px, 1.8fr) repeat(4, minmax(80px, 1fr)); gap: 12px; align-items: center; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface); }.weight-row > div { min-width: 0; }.weight-row strong, .weight-row span, .weight-row p { display: block; overflow-wrap: anywhere; }.weight-row span { margin-top: 3px; color: var(--cc-muted); font-size: 11px; }.weight-row p { margin: 5px 0 0; color: var(--cc-muted); font-size: 11px; line-height: 1.45; }.weight-row > div:not(:first-child) { text-align: right; }
.review-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 16px; border: 1px solid #b3d8ff; border-radius: 10px; background: #ecf5ff; }.review-actions > div:first-child { display: grid; gap: 4px; min-width: 0; }.review-actions span { color: #606266; font-size: 12px; line-height: 1.5; }.review-actions--disabled { border-color: var(--cc-border); background: var(--cc-surface-muted); }.action-buttons { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.history-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }.history-list li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 10px; position: relative; padding-bottom: 16px; }.history-list li:not(:last-child)::after { content: ''; position: absolute; top: 16px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); }.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 2px; border: 3px solid #909399; border-radius: 50%; background: var(--cc-surface); }.history-marker--success { border-color: #67c23a; }.history-marker--danger { border-color: #f56c6c; }.history-content { min-width: 0; }.history-content > div { display: flex; justify-content: space-between; gap: 12px; }.history-content time, .history-content > span { color: var(--cc-muted); font-size: 11px; }.history-content p { margin: 5px 0 4px; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
.el-button { min-height: 36px; }.el-button :deep(svg) { flex: 0 0 auto; }.review-panel :deep(.el-dialog__body) { display: grid; gap: 14px; }
@media (max-width: 720px) { .review-header, .section-heading, .review-actions { align-items: flex-start; flex-direction: column; }.review-header-status, .action-buttons { width: 100%; justify-content: space-between; }.comparison-grid { grid-template-columns: 1fr; }.weight-row { grid-template-columns: 1fr 1fr; }.weight-row > div:not(:first-child) { text-align: left; }.review-actions .action-buttons .el-button { flex: 1 1 140px; }.review-actions .action-buttons { justify-content: stretch; }.review-actions .action-buttons :deep(.el-button) { width: 100%; } }
@media (max-width: 380px) { .review-header h2 { font-size: 19px; }.review-header p { font-size: 12px; }.comparison-card { padding: 13px; }.weight-row { grid-template-columns: 1fr; }.review-actions { padding: 13px; } }
</style>
+47
View File
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
approveMeasurement,
getMeasurementReviewDetail,
listMeasurementReviewHistory,
rejectMeasurement,
releaseRejectedMeasurement,
} from './api'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), post: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
describe('measurement review API', () => {
beforeEach(() => {
vi.mocked(http.get).mockReset()
vi.mocked(http.post).mockReset()
})
it('keeps the measurement detail and review history prefixes from the backend controllers', async () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', message: 'success', data: { measurementId: '7' } })
.mockResolvedValueOnce({ code: '200', message: 'success', data: { records: [], page: '1', size: '20', total: '0', pages: '0' } })
await getMeasurementReviewDetail('10001', '90001')
await listMeasurementReviewHistory('10001', '90001')
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/inbounds/90001/measurement', { params: { tenantId: '10001' } })
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/inbounds/90001/measurement-review-history', { params: { tenantId: '10001', page: 1, size: 20 } })
})
it('normalizes string pagination and sends idempotency headers for review writes', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '3', pages: '1' } })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null })
await expect(listMeasurementReviewHistory('10001', '90001', 2, 20)).resolves.toMatchObject({ page: 2, total: 3 })
await approveMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 4 }, 'approve-key')
await rejectMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 4, reason: '箱规照片不清晰' }, 'reject-key')
await releaseRejectedMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 5, reason: '客户确认后放行' }, 'release-key')
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/inbounds/90001/measurements/approve', { measurementId: '7', expectedVersion: 4 }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'approve-key' } })
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/inbounds/90001/measurements/reject', { measurementId: '7', expectedVersion: 4, reason: '箱规照片不清晰' }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'reject-key' } })
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/inbounds/90001/measurements/exception-release', { measurementId: '7', expectedVersion: 5, reason: '客户确认后放行' }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'release-key' } })
})
})
+100
View File
@@ -0,0 +1,100 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
ExceptionReleaseRequest,
FeeSnapshotAfterApprove,
MeasurementReviewDetail,
MeasurementReviewHistoryPage,
MeasurementReviewHistoryPayload,
MeasurementReviewRequest,
} from './types'
async function getData<T>(path: string, params: Record<string, unknown>) {
const response = await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(path, { params })
return unwrapApiResponse(response)
}
function idempotencyKey() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
return `review-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
async function postReview<T>(path: string, tenantId: string, body: unknown, key = idempotencyKey()) {
const response = await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(path, body, {
params: { tenantId },
headers: { 'Idempotency-Key': key },
})
return unwrapApiResponse(response)
}
function paginationNumber(value: number | string, fallback: number) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : fallback
}
export function getMeasurementReviewDetail(tenantId: string, inboundId: string) {
return getData<MeasurementReviewDetail>(
`/platform/v1/first-leg/inbounds/${inboundId}/measurement`,
{ tenantId },
)
}
export async function listMeasurementReviewHistory(
tenantId: string,
inboundId: string,
page = 1,
size = 20,
) {
const result = await getData<MeasurementReviewHistoryPayload>(
`/platform/v1/inbounds/${inboundId}/measurement-review-history`,
{ tenantId, page, size },
)
return {
...result,
page: paginationNumber(result.page, page),
size: paginationNumber(result.size, size),
total: paginationNumber(result.total, 0),
pages: paginationNumber(result.pages, 0),
} satisfies MeasurementReviewHistoryPage
}
export function approveMeasurement(
tenantId: string,
inboundId: string,
request: MeasurementReviewRequest,
key?: string,
) {
return postReview<FeeSnapshotAfterApprove>(
`/platform/v1/inbounds/${inboundId}/measurements/approve`,
tenantId,
request,
key,
)
}
export function rejectMeasurement(
tenantId: string,
inboundId: string,
request: MeasurementReviewRequest,
key?: string,
) {
return postReview<void>(
`/platform/v1/inbounds/${inboundId}/measurements/reject`,
tenantId,
request,
key,
)
}
export function releaseRejectedMeasurement(
tenantId: string,
inboundId: string,
request: ExceptionReleaseRequest,
key?: string,
) {
return postReview<void>(
`/platform/v1/inbounds/${inboundId}/measurements/exception-release`,
tenantId,
request,
key,
)
}
+3
View File
@@ -0,0 +1,3 @@
export { default as MeasurementReviewPanel } from './MeasurementReviewPanel.vue'
export * from './api'
export type * from './types'
+123
View File
@@ -0,0 +1,123 @@
export type MeasurementReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | string
export interface MeasurementReviewItem {
id: string
itemNo: string
boxSpecification: string | null
packageCount: number | null
lengthCm: number | null
widthCm: number | null
heightCm: number | null
girthCm: number | null
unitWeightKg: number | null
unitVolumeCbm: number | null
totalWeightKg: number | null
totalVolumeCbm: number | null
volumeWeightKg: number | null
intelligentImageFileId: string | null
overLimitWarning: string | null
outerLengthCm: number | null
outerWidthCm: number | null
outerHeightCm: number | null
remark: string | null
}
export interface MeasurementReviewWeightResult {
id: string
fileId: string | null
ocrProvider: string | null
ocrRawText: string | null
ocrWeightValue: number | null
ocrWeightUnit: string | null
confidence: number | null
adjustedWeightValue: number | null
adjustedWeightUnit: string | null
finalWeightKg: number | null
recognitionStatus: string | null
adjustmentReason: string | null
adjustedBy: string | null
adjustedAt: string | null
confirmedBy: string | null
confirmedAt: string | null
version: number
}
export interface MeasurementReviewDetail {
inboundId: string
inboundCode: string | null
companyName: string | null
channelCode: string | null
channelName: string | null
processStatus: string | null
measureReviewStatus: MeasurementReviewStatus | null
recommendedPalletType: string | null
reviewReason: string | null
reviewerId: string | null
reviewerName: string | null
measurementId: string
sourceType: string | null
measurementStatus: string | null
version: number
originalDocumentNo: string | null
originalGrossWeightKg: number | null
originalVolumeCbm: number | null
actualPackageCount: number | null
actualGrossWeightKg: number | null
actualVolumeCbm: number | null
volumeWeightKg: number | null
actualGoodsName: string | null
items: MeasurementReviewItem[]
weightResults: MeasurementReviewWeightResult[]
}
export interface MeasurementReviewRecord {
id: string
tenantId: string
inboundId: string
measurementId: string
reviewResult: string | null
reviewReason: string | null
exceptionRelease: boolean
releaseReason: string | null
allowRejectedConfig: boolean
reviewerName: string | null
reviewedAt: string | null
}
export interface MeasurementReviewHistoryPage {
records: MeasurementReviewRecord[]
page: number
size: number
total: number
pages: number
}
export interface MeasurementReviewHistoryPayload extends Omit<MeasurementReviewHistoryPage, 'page' | 'size' | 'total' | 'pages'> {
page: number | string
size: number | string
total: number | string
pages: number | string
}
export interface MeasurementReviewRequest {
measurementId: string
expectedVersion: number
reason?: string
}
export interface ExceptionReleaseRequest {
measurementId: string
expectedVersion: number
reason: string
}
export interface FeeSnapshotAfterApprove {
feeId: string | null
feeNo: string | null
feeVersion: number | null
feeStatus: string | null
inboundId: string | null
totalAmount: number | null
currency: string | null
nextProcessStatus: string | null
}
+24
View File
@@ -0,0 +1,24 @@
import type { RouteRecordRaw } from 'vue-router'
export const inboundRoutes = [
{
path: 'inbounds',
name: 'inbound-orders',
component: () => import('./pages/InboundOrdersView.vue'),
meta: {
title: '入库单',
description: '查询头程入库单并跟进测量、审核、费用和入库进度',
permission: 'platform:firstleg:inbound:list',
},
},
{
path: 'inbounds/:inboundId',
name: 'inbound-order-detail',
component: () => import('./pages/InboundOrderDetailView.vue'),
meta: {
title: '入库作业详情',
description: '处理测量、称重、审核并查看费用和托盘库位结果',
permission: 'platform:firstleg:inbound:view',
},
},
] satisfies RouteRecordRaw[]
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
confirmWeightResult,
correctWeightResult,
createWeightResult,
downloadWeightImage,
getWeightWorkspace,
retryWeightRecognition,
uploadWeightImage,
} from './weightOcr'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
ApiResponseError: class ApiResponseError extends Error {},
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
describe('inbound weight OCR API', () => {
beforeEach(() => {
vi.mocked(http.get).mockReset()
vi.mocked(http.post).mockReset()
vi.mocked(http.put).mockReset()
})
it('loads the measurement workspace using the real firstleg path', async () => {
const detail = { inboundId: '9007199254740993', weightResults: [] }
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: detail })
await expect(getWeightWorkspace('10001', '9007199254740993')).resolves.toEqual(detail)
expect(http.get).toHaveBeenCalledWith(
'/platform/v1/first-leg/inbounds/9007199254740993/measurement',
{ params: { tenantId: '10001' } },
)
})
it('uploads and retries against the file service without converting long IDs', async () => {
const result = { fileId: '9007199254740995', recognitionStatus: 'SUCCESS' }
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: result })
const file = new File(['image'], 'weight.png', { type: 'image/png' })
await expect(uploadWeightImage('10001', '9007199254740993', file)).resolves.toEqual(result)
const [uploadPath, body] = vi.mocked(http.post).mock.calls[0]!
expect(uploadPath).toBe('/platform/v1/files/measurements/10001/9007199254740993')
expect(body).toBeInstanceOf(FormData)
expect((body as FormData).get('file')).toBe(file)
await retryWeightRecognition('10001', '9007199254740993', '9007199254740995')
expect(http.post).toHaveBeenLastCalledWith(
'/platform/v1/files/measurements/10001/9007199254740993/9007199254740995/recognize',
)
})
it('registers, corrects, and confirms a weight result using the implemented contracts', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1' } })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', version: 1 } })
const createPayload = {
fileId: '9007199254740995',
ocrProvider: 'FILE_WEIGHT_OCR',
ocrRawText: null,
ocrWeightValue: 12.5,
ocrWeightUnit: 'KG',
confidence: 0.98,
}
await createWeightResult('10001', '20001', createPayload)
expect(http.post).toHaveBeenNthCalledWith(
1,
'/platform/v1/first-leg/inbounds/20001/weight-results',
createPayload,
{ params: { tenantId: '10001' } },
)
const correction = { version: 0, adjustedWeightValue: 12.8, adjustedWeightUnit: 'KG' as const, adjustmentReason: '读数模糊' }
await correctWeightResult('10001', '20001', '30001', correction)
expect(http.put).toHaveBeenCalledWith(
'/platform/v1/first-leg/inbounds/20001/weight-results/30001',
correction,
{ params: { tenantId: '10001' } },
)
await confirmWeightResult('10001', '20001', '30001', { version: 1 })
expect(http.post).toHaveBeenLastCalledWith(
'/platform/v1/first-leg/inbounds/20001/weight-results/30001/confirm',
{ version: 1 },
{ params: { tenantId: '10001' } },
)
})
it('downloads an authenticated image as a blob', async () => {
const image = new Blob(['png'], { type: 'image/png' })
vi.mocked(http.get).mockResolvedValue(image)
await expect(downloadWeightImage('10001', '20001', '40001')).resolves.toBe(image)
expect(http.get).toHaveBeenCalledWith(
'/platform/v1/files/measurements/10001/20001/40001',
{ responseType: 'blob' },
)
})
})
@@ -0,0 +1,123 @@
import axios from 'axios'
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
ConfirmWeightResultRequest,
CorrectWeightResultRequest,
CreateWeightResultRequest,
WeightImageUploadResult,
WeightResultResource,
WeightWorkspaceResource,
} from '../types'
const measurementBasePath = '/platform/v1/first-leg/inbounds'
const imageBasePath = '/platform/v1/files/measurements'
const maxErrorEnvelopeBytes = 1024 * 1024
function segment(value: string) {
return encodeURIComponent(value)
}
async function parseBlobError(blob: Blob, status?: number) {
if (!blob.type.toLowerCase().includes('json')) return null
if (blob.size > maxErrorEnvelopeBytes) {
return new ApiResponseError('图片接口返回了超出限制的 JSON 响应', 'INVALID_IMAGE_RESPONSE', status)
}
try {
const payload = JSON.parse(await blob.text()) as Partial<ApiEnvelope<unknown>>
return new ApiResponseError(
payload.code === '200' ? '图片接口未返回图片内容' : payload.message || '称重图片读取失败',
payload.code === '200' ? 'INVALID_IMAGE_RESPONSE' : payload.code || (status ? `HTTP_${status}` : 'IMAGE_FAILED'),
status,
payload.data,
payload.requestId,
payload.traceId,
)
} catch {
return new ApiResponseError('图片接口返回了无法识别的响应', 'INVALID_IMAGE_RESPONSE', status)
}
}
export async function getWeightWorkspace(tenantId: string, inboundId: string) {
const response = await http.get<ApiEnvelope<WeightWorkspaceResource>, ApiEnvelope<WeightWorkspaceResource>>(
`${measurementBasePath}/${segment(inboundId)}/measurement`,
{ params: { tenantId } },
)
return unwrapApiResponse(response)
}
export async function uploadWeightImage(tenantId: string, inboundId: string, file: File) {
const body = new FormData()
body.append('file', file)
const response = await http.post<ApiEnvelope<WeightImageUploadResult>, ApiEnvelope<WeightImageUploadResult>>(
`${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}`,
body,
)
return unwrapApiResponse(response)
}
export async function retryWeightRecognition(tenantId: string, inboundId: string, fileId: string) {
const response = await http.post<ApiEnvelope<WeightImageUploadResult>, ApiEnvelope<WeightImageUploadResult>>(
`${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}/${segment(fileId)}/recognize`,
)
return unwrapApiResponse(response)
}
export async function downloadWeightImage(tenantId: string, inboundId: string, fileId: string) {
try {
const blob = await http.get<Blob, Blob>(
`${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}/${segment(fileId)}`,
{ responseType: 'blob' },
)
const responseError = await parseBlobError(blob, 200)
if (responseError) throw responseError
return blob
} catch (error) {
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
const responseError = await parseBlobError(error.response.data, error.response.status)
if (responseError) throw responseError
}
throw error
}
}
export async function createWeightResult(
tenantId: string,
inboundId: string,
payload: CreateWeightResultRequest,
) {
const response = await http.post<ApiEnvelope<WeightResultResource>, ApiEnvelope<WeightResultResource>>(
`${measurementBasePath}/${segment(inboundId)}/weight-results`,
payload,
{ params: { tenantId } },
)
return unwrapApiResponse(response)
}
export async function correctWeightResult(
tenantId: string,
inboundId: string,
resultId: string,
payload: CorrectWeightResultRequest,
) {
const response = await http.put<ApiEnvelope<WeightResultResource>, ApiEnvelope<WeightResultResource>>(
`${measurementBasePath}/${segment(inboundId)}/weight-results/${segment(resultId)}`,
payload,
{ params: { tenantId } },
)
return unwrapApiResponse(response)
}
export async function confirmWeightResult(
tenantId: string,
inboundId: string,
resultId: string,
payload: ConfirmWeightResultRequest,
) {
const response = await http.post<ApiEnvelope<WeightResultResource>, ApiEnvelope<WeightResultResource>>(
`${measurementBasePath}/${segment(inboundId)}/weight-results/${segment(resultId)}/confirm`,
payload,
{ params: { tenantId } },
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,49 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/weight/components/WeightOcrPanel.vue'), 'utf8')
describe('WeightOcrPanel contract', () => {
it('uses customer language at the page boundary while retaining a compatibility input', () => {
expect(source).toContain("defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()")
expect(source).toContain("const customerId = computed(() => props.customerId || props.tenantId || '')")
expect(source).toContain("defineEmits<{ changed: [] }>()")
expect(source).toContain("defineExpose({ reload: loadWorkspace })")
const template = source.match(/<template>([\s\S]*?)<\/template>/)?.[1] || ''
expect(template).not.toContain('租户')
})
it('covers upload, preview, OCR retry, correction, confirmation, and server totals', () => {
expect(source).toContain('uploadWeightImage')
expect(source).toContain('downloadWeightImage')
expect(source).toContain('retryWeightRecognition')
expect(source).toContain('correctWeightResult')
expect(source).toContain('confirmWeightResult')
expect(source).toContain('实际总毛重(服务端汇总)')
expect(source).toContain('称重图片序号 #{{ index + 1 }}')
expect(source).toContain('workspace.value?.actualGrossWeightKg')
})
it('matches backend image limits and preserves missing OCR text honestly', () => {
expect(source).toContain("['image/jpeg', 'image/png', 'image/bmp']")
expect(source).toContain('5 * 1024 * 1024')
expect(source).toContain('图片服务未返回 OCR 原文')
expect(source).toContain("adjustmentReason: 'OCR 重试识别结果'")
})
it('gates every action with the implemented permissions and editable statuses', () => {
expect(source).toContain("platform:firstleg:inbound:measure:view")
expect(source).toContain("platform:firstleg:inbound:weight:edit")
expect(source).toContain("platform:firstleg:inbound:weight:confirm")
expect(source).toContain("new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])")
})
it('provides error tracking, empty, loading, and narrow-screen states', () => {
expect(source).toContain('<ApiErrorMeta')
expect(source).toContain('<LoadErrorState')
expect(source).toContain('尚未上传称重图片')
expect(source).toContain('v-loading="loading"')
expect(source).toContain('@media (max-width: 360px)')
})
})
@@ -0,0 +1,530 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import {
CheckCircle2,
Eye,
FileImage,
ImagePlus,
Pencil,
RefreshCw,
Scale,
} from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import {
confirmWeightResult,
correctWeightResult,
createWeightResult,
downloadWeightImage,
getWeightWorkspace,
retryWeightRecognition,
uploadWeightImage,
} from '../api/weightOcr'
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
const props = defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const fileInput = ref<HTMLInputElement>()
const correctionFormRef = ref<FormInstance>()
const workspace = ref<WeightWorkspaceResource | null>(null)
const loading = ref(false)
const uploading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const activeAction = ref('')
const correctionOpen = ref(false)
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewError = ref<ApiErrorDetails | null>(null)
const previewUrl = ref('')
const correction = reactive({
resultId: '',
version: 0,
value: null as number | null,
unit: 'KG' as 'KG' | 'LB',
reason: '',
})
let loadSequence = 0
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
const customerId = computed(() => props.customerId || props.tenantId || '')
const canEdit = computed(() => access.can('platform:firstleg:inbound:weight:edit'))
const canConfirm = computed(() => access.can('platform:firstleg:inbound:weight:confirm'))
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const editable = computed(() => Boolean(workspace.value && editableStatuses.has(workspace.value.processStatus)))
const rows = computed(() => workspace.value?.weightResults ?? [])
const confirmedCount = computed(() => rows.value.filter((row) => row.recognitionStatus === 'CONFIRMED').length)
const pendingCount = computed(() => rows.value.length - confirmedCount.value)
const confirmedTotal = computed(() => workspace.value?.actualGrossWeightKg ?? null)
const correctionRules: FormRules<typeof correction> = {
value: [
{ required: true, message: '请输入有效重量', trigger: 'blur' },
{
validator: (_rule, value, callback) => Number(value) > 0 ? callback() : callback(new Error('重量必须大于 0')),
trigger: 'blur',
},
],
unit: [{ required: true, message: '请选择重量单位', trigger: 'change' }],
reason: [{ required: true, message: '请填写人工修正原因', trigger: 'blur' }],
}
function formatWeight(value: number | null, unit = 'KG') {
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
}
function formatConfidence(value: number | null) {
return value == null ? '未返回' : `${(Number(value) * 100).toFixed(1)}%`
}
function formatTime(value: string | null) {
return value?.replace('T', ' ').slice(0, 16) || '—'
}
function statusMeta(status: string) {
if (status === 'CONFIRMED') return { label: '已确认', type: 'success' as const }
if (status === 'PENDING_CONFIRM') return { label: '待确认', type: 'warning' as const }
if (status === 'NEEDS_MANUAL') return { label: '需人工处理', type: 'danger' as const }
return { label: status || '未知', type: 'info' as const }
}
function actionKey(action: string, id: string) {
return `${action}:${id}`
}
function isActionLoading(action: string, id: string) {
return activeAction.value === actionKey(action, id)
}
async function loadWorkspace() {
if (!customerId.value || !props.inboundId || !canView.value) {
workspace.value = null
return
}
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
const result = await getWeightWorkspace(customerId.value, props.inboundId)
if (sequence === loadSequence) workspace.value = result
} catch (error) {
if (sequence !== loadSequence) return
workspace.value = null
loadError.value = getApiErrorDetails(error, '称重与 OCR 数据加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
function chooseFile() {
if (!editable.value) {
ElMessage.warning('当前入库状态不允许上传称重图片')
return
}
fileInput.value?.click()
}
function validateImage(file: File) {
if (!['image/jpeg', 'image/png', 'image/bmp'].includes(file.type)) return '称重图片仅支持 JPG、PNG、BMP'
if (file.size > 5 * 1024 * 1024) return '称重图片不能超过 5MB'
return ''
}
async function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || uploading.value) return
const validationError = validateImage(file)
if (validationError) {
ElMessage.warning(validationError)
return
}
uploading.value = true
actionError.value = null
try {
const upload = await uploadWeightImage(customerId.value, props.inboundId, file)
await createWeightResult(customerId.value, props.inboundId, {
fileId: String(upload.fileId),
ocrProvider: 'FILE_WEIGHT_OCR',
ocrRawText: null,
ocrWeightValue: upload.weightValue,
ocrWeightUnit: upload.weightUnit,
confidence: upload.confidence,
})
ElMessage.success(upload.recognitionStatus === 'FAILED'
? '图片已保存,OCR 未识别出重量,请人工修正或重试'
: '称重图片已上传并完成识别')
await loadWorkspace()
emit('changed')
} catch (error) {
actionError.value = getApiErrorDetails(error, '称重图片上传失败,请检查后重试')
} finally {
uploading.value = false
}
}
function openCorrection(row: WeightResultResource) {
correction.resultId = row.id
correction.version = row.version
correction.value = row.adjustedWeightValue ?? row.ocrWeightValue
correction.unit = (row.adjustedWeightUnit ?? row.ocrWeightUnit) === 'LB' ? 'LB' : 'KG'
correction.reason = ''
correctionOpen.value = true
void correctionFormRef.value?.clearValidate()
}
async function submitCorrection() {
if (!await correctionFormRef.value?.validate().catch(() => false) || correction.value == null) return
const key = actionKey('correct', correction.resultId)
activeAction.value = key
actionError.value = null
try {
await correctWeightResult(customerId.value, props.inboundId, correction.resultId, {
version: correction.version,
adjustedWeightValue: correction.value,
adjustedWeightUnit: correction.unit,
adjustmentReason: correction.reason.trim(),
})
correctionOpen.value = false
ElMessage.success('重量修正已保存,请确认后计入总重量')
await loadWorkspace()
emit('changed')
} catch (error) {
actionError.value = getApiErrorDetails(error, '重量修正失败,请刷新后重试')
} finally {
activeAction.value = ''
}
}
async function confirmResult(row: WeightResultResource) {
const key = actionKey('confirm', row.id)
activeAction.value = key
actionError.value = null
try {
await confirmWeightResult(customerId.value, props.inboundId, row.id, { version: row.version })
ElMessage.success('重量已确认并计入实际总重量')
await loadWorkspace()
emit('changed')
} catch (error) {
actionError.value = getApiErrorDetails(error, '重量确认失败,请刷新后重试')
} finally {
activeAction.value = ''
}
}
async function retryOcr(row: WeightResultResource) {
const key = actionKey('retry', row.id)
activeAction.value = key
actionError.value = null
try {
const result = await retryWeightRecognition(customerId.value, props.inboundId, row.fileId)
if (result.weightValue == null || !result.weightUnit) {
throw new Error(result.error || 'OCR 仍未识别出有效重量,可改用人工修正')
}
await correctWeightResult(customerId.value, props.inboundId, row.id, {
version: row.version,
adjustedWeightValue: result.weightValue,
adjustedWeightUnit: result.weightUnit.toUpperCase().startsWith('LB') ? 'LB' : 'KG',
adjustmentReason: 'OCR 重试识别结果',
})
ElMessage.success('OCR 重试成功,请核对并确认重量')
await loadWorkspace()
emit('changed')
} catch (error) {
actionError.value = getApiErrorDetails(error, 'OCR 重试失败,可稍后重试或人工修正')
} finally {
activeAction.value = ''
}
}
function releasePreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
async function openPreview(row: WeightResultResource) {
releasePreview()
previewError.value = null
previewOpen.value = true
previewLoading.value = true
try {
const blob = await downloadWeightImage(customerId.value, props.inboundId, row.fileId)
previewUrl.value = URL.createObjectURL(blob)
} catch (error) {
previewError.value = getApiErrorDetails(error, '称重图片读取失败,请稍后重试')
} finally {
previewLoading.value = false
}
}
watch(
() => [customerId.value, props.inboundId, canView.value] as const,
() => {
++loadSequence
workspace.value = null
loadError.value = null
actionError.value = null
correctionOpen.value = false
previewOpen.value = false
releasePreview()
void loadWorkspace()
},
{ immediate: true },
)
watch(previewOpen, (open) => { if (!open) releasePreview() })
onBeforeUnmount(releasePreview)
defineExpose({ reload: loadWorkspace })
</script>
<template>
<section class="weight-workspace" aria-labelledby="weight-ocr-title">
<header class="weight-heading">
<div>
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片与 OCR</h3>
<p>逐张核对识别值并确认,服务端汇总所有已确认图片的实际总重量。</p>
</div>
<div class="weight-heading__actions">
<input
ref="fileInput"
class="visually-hidden-input"
type="file"
accept="image/jpeg,image/png,image/bmp"
aria-label="选择称重图片"
@change="handleFileChange"
/>
<el-button
v-if="canEdit"
type="primary"
:loading="uploading"
:disabled="loading || !editable"
@click="chooseFile"
><ImagePlus :size="16" />上传称重图片</el-button>
</div>
</header>
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
当前账号缺少入库测量查看权限。
</el-alert>
<template v-else>
<LoadErrorState
v-if="loadError"
title="称重数据加载失败"
:message="loadError.message"
:loading="loading"
compact
@retry="loadWorkspace"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<template v-else>
<div class="weight-metrics" aria-live="polite" :aria-busy="loading">
<div><span>实际总毛重(服务端汇总)</span><strong>{{ formatWeight(confirmedTotal) }}</strong></div>
<div><span>已确认图片</span><strong>{{ confirmedCount }}</strong></div>
<div><span>待处理图片</span><strong :class="{ 'is-warning': pendingCount > 0 }">{{ pendingCount }}</strong></div>
</div>
<el-alert v-if="workspace && !editable" type="info" :closable="false" show-icon class="weight-state-note">
当前入库状态为 {{ workspace.processStatus }},称重数据仅供查看。
</el-alert>
<LoadErrorState
v-if="actionError"
title="操作未完成"
:message="actionError.message"
:show-retry="false"
compact
class="weight-action-error"
>
<template #details><ApiErrorMeta :details="actionError" /></template>
</LoadErrorState>
<div v-loading="loading" class="weight-list" aria-live="polite">
<div v-if="!loading && rows.length === 0" class="weight-empty">
<FileImage :size="28" aria-hidden="true" />
<strong>尚未上传称重图片</strong>
<p>上传 JPG、PNG 或 BMP 图片,单张不超过 5MB。</p>
</div>
<article v-for="(row, index) in rows" :key="row.id" class="weight-row">
<div class="weight-row__identity">
<span class="weight-row__icon"><FileImage :size="18" aria-hidden="true" /></span>
<div>
<strong>称重图片序号 #{{ index + 1 }}</strong>
<small>文件 ID {{ row.fileId }} · {{ row.ocrProvider || 'OCR 服务未标识' }}</small>
</div>
<el-tag :type="statusMeta(row.recognitionStatus).type" effect="light">
{{ statusMeta(row.recognitionStatus).label }}
</el-tag>
</div>
<dl class="weight-row__facts">
<div><dt>OCR 原值</dt><dd>{{ formatWeight(row.ocrWeightValue, row.ocrWeightUnit || '') }}</dd></div>
<div><dt>置信度</dt><dd>{{ formatConfidence(row.confidence) }}</dd></div>
<div><dt>人工修正</dt><dd>{{ formatWeight(row.adjustedWeightValue, row.adjustedWeightUnit || '') }}</dd></div>
<div><dt>最终计入</dt><dd class="weight-final">{{ formatWeight(row.finalWeightKg) }}</dd></div>
</dl>
<div class="weight-row__raw">
<span>OCR 原文</span>
<p>{{ row.ocrRawText || '图片服务未返回 OCR 原文' }}</p>
</div>
<div v-if="row.adjustmentReason || row.confirmedAt" class="weight-row__audit">
<span v-if="row.adjustmentReason">修正原因:{{ row.adjustmentReason }}</span>
<span v-if="row.confirmedAt">确认时间:{{ formatTime(row.confirmedAt) }}</span>
</div>
<footer class="weight-row__actions">
<el-button link type="primary" @click="openPreview(row)"><Eye :size="15" />查看图片</el-button>
<el-button
v-if="canEdit && editable && row.recognitionStatus !== 'CONFIRMED'"
link
type="primary"
:loading="isActionLoading('retry', row.id)"
:disabled="Boolean(activeAction)"
@click="retryOcr(row)"
><RefreshCw :size="15" />重试 OCR</el-button>
<el-button
v-if="canEdit && editable"
link
type="primary"
:disabled="Boolean(activeAction)"
@click="openCorrection(row)"
><Pencil :size="15" />人工修正</el-button>
<el-button
v-if="canConfirm && editable && row.recognitionStatus !== 'CONFIRMED'"
link
type="success"
:loading="isActionLoading('confirm', row.id)"
:disabled="Boolean(activeAction) || row.recognitionStatus === 'NEEDS_MANUAL'"
@click="confirmResult(row)"
><CheckCircle2 :size="15" />确认重量</el-button>
</footer>
</article>
</div>
</template>
</template>
<AppDialog
v-model="correctionOpen"
title="人工修正重量"
confirm-text="保存修正"
:confirm-loading="activeAction.startsWith('correct:')"
@confirm="submitCorrection"
>
<el-form ref="correctionFormRef" :model="correction" :rules="correctionRules" label-position="top">
<div class="correction-grid">
<el-form-item label="修正重量" prop="value">
<el-input-number v-model="correction.value" :min="0.001" :precision="3" :controls="false" />
</el-form-item>
<el-form-item label="单位" prop="unit">
<el-segmented v-model="correction.unit" :options="['KG', 'LB']" />
</el-form-item>
</div>
<el-form-item label="修正原因" prop="reason">
<el-input
v-model="correction.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="说明读数模糊、单位识别错误等具体原因"
/>
</el-form-item>
</el-form>
</AppDialog>
<AppDialog
v-model="previewOpen"
title="称重图片"
:loading="previewLoading"
:show-footer="false"
width="min(760px, calc(100vw - 28px))"
>
<LoadErrorState
v-if="previewError"
title="图片读取失败"
:message="previewError.message"
:show-retry="false"
compact
>
<template #details><ApiErrorMeta :details="previewError" /></template>
</LoadErrorState>
<img v-else-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
</AppDialog>
</section>
</template>
<style scoped>
.weight-workspace { min-width: 0; color: var(--el-text-color-primary); }
.weight-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
.weight-heading h3 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 15px; }
.weight-heading h3 svg { color: var(--cc-primary); }
.weight-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
.weight-heading__actions { flex: 0 0 auto; }
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.weight-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 18px 0; border: 1px solid var(--cc-border); border-radius: 8px; background: #f8fafc; }
.weight-metrics > div { min-width: 0; padding: 15px 18px; border-right: 1px solid var(--cc-border); }
.weight-metrics > div:last-child { border-right: 0; }
.weight-metrics span { display: block; margin-bottom: 5px; color: var(--cc-muted); font-size: 11px; }
.weight-metrics strong { font-size: 20px; line-height: 1.2; }
.weight-metrics strong.is-warning { color: #b54708; }
.weight-state-note, .weight-action-error { margin-bottom: 14px; }
.weight-list { min-height: 120px; }
.weight-empty { display: grid; min-height: 180px; place-items: center; align-content: center; gap: 8px; padding: 24px; color: var(--cc-muted); text-align: center; }
.weight-empty svg { color: #98a2b3; }
.weight-empty strong { color: #344054; font-size: 14px; }
.weight-empty p { margin: 0; font-size: 12px; }
.weight-row { padding: 18px 0; border-bottom: 1px solid var(--cc-border); }
.weight-row:last-child { border-bottom: 0; }
.weight-row__identity { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; align-items: center; gap: 10px; }
.weight-row__icon { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 7px; background: #eef4ff; color: var(--cc-primary); }
.weight-row__identity strong, .weight-row__identity small { display: block; min-width: 0; overflow-wrap: anywhere; }
.weight-row__identity strong { font-size: 13px; }
.weight-row__identity small { margin-top: 3px; color: var(--cc-muted); font-size: 11px; }
.weight-row__facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 14px 0 0; background: var(--cc-border); }
.weight-row__facts > div { min-width: 0; padding: 11px 12px; background: #f8fafc; }
.weight-row__facts dt { color: var(--cc-muted); font-size: 10px; }
.weight-row__facts dd { margin: 4px 0 0; font-size: 12px; font-weight: 650; overflow-wrap: anywhere; }
.weight-row__facts .weight-final { color: #067647; }
.weight-row__raw { display: grid; grid-template-columns: 78px minmax(0, 1fr); gap: 10px; margin-top: 12px; font-size: 11px; line-height: 1.55; }
.weight-row__raw span { color: var(--cc-muted); }
.weight-row__raw p { margin: 0; color: #475467; overflow-wrap: anywhere; white-space: pre-wrap; }
.weight-row__audit { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; color: var(--cc-muted); font-size: 11px; line-height: 1.5; }
.weight-row__actions { display: flex; flex-wrap: wrap; gap: 2px 12px; margin-top: 12px; }
.weight-row__actions :deep(.el-button) { margin-left: 0; }
.correction-grid { display: grid; grid-template-columns: minmax(0, 1fr) 150px; gap: 16px; }
.correction-grid :deep(.el-input-number), .correction-grid :deep(.el-segmented) { width: 100%; }
.weight-preview { display: block; max-width: 100%; max-height: min(68vh, 720px); margin: 0 auto; object-fit: contain; }
@media (max-width: 720px) {
.weight-heading { flex-direction: column; }
.weight-heading__actions, .weight-heading__actions :deep(.el-button) { width: 100%; }
.weight-heading__actions :deep(.el-button), .weight-row__actions :deep(.el-button) { min-height: 44px; }
.weight-metrics { grid-template-columns: 1fr; }
.weight-metrics > div { border-right: 0; border-bottom: 1px solid var(--cc-border); }
.weight-metrics > div:last-child { border-bottom: 0; }
.weight-row__facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.weight-row__raw { grid-template-columns: 1fr; gap: 4px; }
.weight-row__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.weight-row__actions :deep(.el-button) { width: 100%; justify-content: flex-start; }
.correction-grid { grid-template-columns: 1fr; gap: 0; }
}
@media (max-width: 360px) {
.weight-row__identity { grid-template-columns: 32px minmax(0, 1fr); }
.weight-row__identity > .el-tag { grid-column: 2; justify-self: start; }
.weight-row__facts, .weight-row__actions { grid-template-columns: 1fr; }
}
</style>
+3
View File
@@ -0,0 +1,3 @@
export { default as WeightOcrPanel } from './components/WeightOcrPanel.vue'
export type { WeightResultResource, WeightWorkspaceResource } from './types'
+62
View File
@@ -0,0 +1,62 @@
export type WeightRecognitionStatus = 'PENDING_CONFIRM' | 'NEEDS_MANUAL' | 'CONFIRMED' | string
export interface WeightResultResource {
id: string
fileId: string
ocrProvider: string | null
ocrRawText: string | null
ocrWeightValue: number | null
ocrWeightUnit: string | null
confidence: number | null
adjustedWeightValue: number | null
adjustedWeightUnit: string | null
finalWeightKg: number | null
recognitionStatus: WeightRecognitionStatus
adjustmentReason: string | null
adjustedBy: string | null
adjustedAt: string | null
confirmedBy: string | null
confirmedAt: string | null
version: number
}
export interface WeightWorkspaceResource {
inboundId: string
inboundCode: string
processStatus: string
actualGrossWeightKg: number | null
weightResults: WeightResultResource[]
}
export interface WeightImageUploadResult {
fileId: string
fileName: string
contentType: string
sizeBytes: number
recognitionStatus: string
weightValue: number | null
weightUnit: string | null
confidence: number | null
error: string | null
}
export interface CreateWeightResultRequest {
fileId: string
ocrProvider: string | null
ocrRawText: string | null
ocrWeightValue: number | null
ocrWeightUnit: string | null
confidence: number | null
}
export interface CorrectWeightResultRequest {
version: number
adjustedWeightValue: number
adjustedWeightUnit: 'KG' | 'LB'
adjustmentReason: string
}
export interface ConfirmWeightResultRequest {
version: number
}
@@ -96,7 +96,7 @@ function normalizePartner(resource: PlatformPartnerResource): PlatformPartnerRes
}
}
export async function listPlatformPartners(tenantId: number | string, query: PlatformPartnerQuery) {
export async function listPlatformPartners(tenantId: number | string | undefined, query: PlatformPartnerQuery) {
const response = await http.get<ApiEnvelope<PlatformPartnerPagePayload>, ApiEnvelope<PlatformPartnerPagePayload>>(basePath, {
params: { tenantId, ...query },
})
@@ -0,0 +1 @@
export * from './api/platformPartners'
@@ -8,8 +8,8 @@ import {
} from '@/modules/master-data/counterparties/api/platformPartners'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { listPlatformTenants, type PlatformTenantResource } from '../../../iam/api/platformIam'
import PlatformTenantSelector from '../../../iam/components/PlatformTenantSelector.vue'
import PartnersView from './PartnersView.vue'
vi.mock('@/modules/master-data/counterparties/api/platformPartners', () => ({
@@ -111,12 +111,14 @@ describe('PartnersView real API integration', () => {
})
})
it('loads the fixed tenant page and renders the real partner fields', async () => {
it('loads all enterprises by default and renders the real partner fields', async () => {
const wrapper = await mountView()
expect(listPlatformPartners).toHaveBeenCalledWith(10001, {
expect(listPlatformPartners).toHaveBeenCalledWith(undefined, {
page: 1, size: 20, keyword: undefined, type: undefined, status: undefined,
})
expect(wrapper.find('input[aria-label="所属企业"]').exists()).toBe(true)
expect(wrapper.text()).toContain('全部企业 · 共 1 个往来单位')
expect(wrapper.text()).toContain('深圳示例贸易有限公司')
expect(wrapper.text()).toContain('CUS-001')
expect(wrapper.text()).toContain('contact@example.com')
@@ -131,7 +133,10 @@ describe('PartnersView real API integration', () => {
it('preserves a snowflake tenant id as a string when switching enterprises', async () => {
const wrapper = await mountView()
wrapper.findComponent(PlatformTenantSelector).vm.$emit('update:modelValue', snowflakeTenantId)
const toolbar = wrapper.findComponent(QueryToolbar)
const model = { tenantId: snowflakeTenantId, keyword: '', type: '', status: '' }
toolbar.vm.$emit('update:modelValue', model)
toolbar.vm.$emit('search', model)
await flushPromises()
expect(listPlatformPartners).toHaveBeenLastCalledWith(snowflakeTenantId, {
@@ -153,13 +158,18 @@ describe('PartnersView real API integration', () => {
await flushPromises()
expect(listPlatformPartners).toHaveBeenCalledTimes(2)
expect(listPlatformPartners).toHaveBeenLastCalledWith(10001, {
expect(listPlatformPartners).toHaveBeenLastCalledWith(undefined, {
page: 1, size: 20, keyword: '深圳', type: undefined, status: undefined,
})
})
it('shows useful placeholders and visible required markers in the create form', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
const model = { tenantId: '10001', keyword: '', type: '', status: '' }
toolbar.vm.$emit('update:modelValue', model)
toolbar.vm.$emit('search', model)
await flushPromises()
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增往来单位')
expect(createButton).toBeDefined()
await createButton?.trigger('click')
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
import { Building2, Download, Eye, MapPin, Pencil, Plus, Power, Upload } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -23,7 +23,7 @@ import {
type SavePlatformPartnerRequest,
} from '@/modules/master-data/counterparties/api/platformPartners'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { PlatformTenantSelector } from '@/modules/iam'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
const pageSize = ref(20)
const access = useAccessPreviewStore()
@@ -42,25 +42,36 @@ const formRef = ref<FormInstance>()
const rows = ref<PlatformPartnerResource[]>([])
const page = ref(1)
const total = ref(0)
const selectedTenantId = ref('')
const loadedTenantId = ref<string | null>(null)
const tenants = ref<PlatformTenantResource[]>([])
const tenantLoadError = ref<ApiErrorDetails | null>(null)
const editorTenantId = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const statusLoadingIds = ref(new Set<string>())
let loadSequence = 0
type PartnerFilters = QueryModel & {
tenantId: string
keyword: string
type: PlatformPartnerType | ''
status: PlatformPartnerStatus | ''
}
const filters = ref<PartnerFilters>({
tenantId: '',
keyword: '',
type: '',
status: '',
})
const appliedFilters = ref<PartnerFilters>({ ...filters.value })
const queryFields: QueryField[] = [
const queryFields = computed<QueryField[]>(() => [
{
key: 'tenantId',
type: 'select',
width: 180,
placeholder: tenantLoadError.value ? '企业列表加载失败' : '全部企业',
props: { 'aria-label': '所属企业', filterable: true, disabled: Boolean(tenantLoadError.value) },
options: tenants.value.map((tenant) => ({ value: String(tenant.id), label: `${tenant.tenantName}(${tenant.tenantCode || tenant.id})` })),
},
{ key: 'keyword', type: 'input', placeholder: '单位名称、编码、联系人或电话' },
{
key: 'type',
@@ -84,7 +95,7 @@ const queryFields: QueryField[] = [
{ label: '停用', value: 'INACTIVE' },
],
},
]
])
interface PartnerForm extends SavePlatformPartnerRequest {
id: string
@@ -119,16 +130,12 @@ const { captureBaseline } = useUnsavedWorkRegistration({
requiredPermission: editorPermission,
})
const tenantReady = computed(() => Boolean(selectedTenantId.value)
&& loadedTenantId.value === selectedTenantId.value
&& !loading.value)
const tenantSwitchDisabled = computed(() => editorOpen.value
|| detailOpen.value
|| importOpen.value
|| saving.value
|| detailLoading.value
|| importing.value
|| statusLoadingIds.value.size > 0)
const selectedEnterpriseId = computed(() => appliedFilters.value.tenantId)
const targetEnterpriseId = computed(() => filters.value.tenantId)
const selectedEnterpriseName = computed(() => {
if (!selectedEnterpriseId.value) return '全部企业'
return tenants.value.find((tenant) => String(tenant.id) === selectedEnterpriseId.value)?.tenantName || `企业 ${selectedEnterpriseId.value}`
})
const rules: FormRules<PartnerForm> = {
partnerCode: [{ required: true, whitespace: true, message: '请输入往来单位编码', trigger: 'blur' }],
@@ -161,6 +168,7 @@ function partnerTypeLabel(type: PlatformPartnerLegacyType) {
const partnerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ key: 'partner', label: '往来单位', minWidth: 210, slotName: 'partner' },
{ key: 'tenant', label: '所属企业', minWidth: 190, showOverflowTooltip: true, slotName: 'tenant' },
{ key: 'type', label: '类型', minWidth: 115, slotName: 'type' },
{ key: 'contact', label: '联系人', minWidth: 165, slotName: 'contact' },
{ key: 'address', label: '地址', minWidth: 240, slotName: 'address' },
@@ -188,30 +196,52 @@ function queryParams() {
}
}
function apiTenantId(value: string) {
function apiTenantId(value: string | undefined) {
if (!value) return undefined
const numeric = Number(value)
return Number.isSafeInteger(numeric) ? numeric : value
}
function requireCurrentTenant() {
if (!tenantReady.value) {
ElMessage.warning('往来单位数据尚未加载完成,请稍后重试')
function requireSelectedEnterprise(action: string) {
if (!targetEnterpriseId.value) {
ElMessage.warning(`请先在筛选条件中选择所属企业,再${action}`)
return null
}
return apiTenantId(selectedTenantId.value)
return apiTenantId(targetEnterpriseId.value)!
}
async function loadPartners(tenantId = selectedTenantId.value) {
if (!tenantId) return
function tenantName(tenantId: number | string) {
return tenants.value.find((tenant) => String(tenant.id) === String(tenantId))?.tenantName || `企业 ${tenantId}`
}
async function loadTenants() {
tenantLoadError.value = null
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200 })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
tenants.value = records
} catch (error) {
tenants.value = []
tenantLoadError.value = getApiErrorDetails(error, '企业列表加载失败,暂时无法按所属企业筛选')
}
}
async function loadPartners() {
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
const result = await listPlatformPartners(apiTenantId(tenantId), queryParams())
const result = await listPlatformPartners(apiTenantId(appliedFilters.value.tenantId), queryParams())
if (sequence !== loadSequence) return
rows.value = result.records
total.value = result.total
loadedTenantId.value = tenantId
} catch (error) {
if (sequence !== loadSequence) return
rows.value = []
@@ -224,6 +254,7 @@ async function loadPartners(tenantId = selectedTenantId.value) {
function searchPartners(model: QueryModel) {
appliedFilters.value = {
tenantId: String(model.tenantId || ''),
keyword: String(model.keyword || ''),
type: (model.type || '') as PlatformPartnerType | '',
status: (model.status || '') as PlatformPartnerStatus | '',
@@ -236,7 +267,9 @@ function searchPartners(model: QueryModel) {
}
function openCreate() {
if (!requireCurrentTenant()) return
const tenantId = requireSelectedEnterprise('新增往来单位')
if (!tenantId) return
editorTenantId.value = String(tenantId)
Object.assign(form, emptyForm())
captureBaseline()
editorOpen.value = true
@@ -248,7 +281,7 @@ function nullable(value: string | null) {
}
function openEdit(row: PlatformPartnerResource) {
if (!requireCurrentTenant()) return
editorTenantId.value = String(row.tenantId)
Object.assign(form, {
id: String(row.id),
partnerCode: row.partnerCode,
@@ -294,8 +327,7 @@ function savePayload(): SavePlatformPartnerRequest {
}
async function openDetail(row: PlatformPartnerResource) {
const tenantId = requireCurrentTenant()
if (!tenantId) return
const tenantId = apiTenantId(String(row.tenantId))!
detailOpen.value = true
detailLoading.value = true
detail.value = null
@@ -311,6 +343,7 @@ async function openDetail(row: PlatformPartnerResource) {
}
function openImport() {
if (!requireSelectedEnterprise('批量导入')) return
importFile.value = null
importResult.value = null
importOpen.value = true
@@ -343,7 +376,7 @@ async function downloadTemplate() {
}
async function submitImport() {
const tenantId = requireCurrentTenant()
const tenantId = requireSelectedEnterprise('批量导入')
if (!tenantId) return
if (!importFile.value) {
ElMessage.warning('请先选择 XLSX 文件')
@@ -382,7 +415,7 @@ function downloadImportErrors() {
}
async function savePartner() {
const tenantId = requireCurrentTenant()
const tenantId = form.id ? apiTenantId(editorTenantId.value) : requireSelectedEnterprise('新增往来单位')
if (!tenantId) return
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
@@ -407,8 +440,7 @@ async function savePartner() {
}
async function toggleStatus(row: PlatformPartnerResource) {
const tenantId = requireCurrentTenant()
if (!tenantId) return
const tenantId = apiTenantId(String(row.tenantId))!
const nextStatus: PlatformPartnerStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
const action = nextStatus === 'ACTIVE' ? '启用' : '停用'
try {
@@ -441,26 +473,23 @@ function formatUpdatedAt(value?: string | null) {
}
watch([page, pageSize], () => { void loadPartners() })
watch(selectedTenantId, (tenantId) => {
editorOpen.value = false
detailOpen.value = false
importOpen.value = false
detail.value = null
importFile.value = null
importResult.value = null
rows.value = []
total.value = 0
loadedTenantId.value = null
page.value = 1
void loadPartners(tenantId)
onMounted(() => {
void loadTenants()
void loadPartners()
})
</script>
<template>
<div class="business-page partners-page">
<PlatformTenantSelector v-model="selectedTenantId" :disabled="tenantSwitchDisabled" />
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="往来单位筛选" :fields="queryFields" @search="searchPartners" />
<el-alert v-if="tenantLoadError" type="warning" :closable="false" show-icon>
<template #title>所属企业列表加载失败</template>
<p>{{ tenantLoadError.message }};往来单位仍按全部企业展示。</p>
<ApiErrorMeta :details="tenantLoadError" />
<el-button link type="warning" @click="loadTenants">重新加载企业列表</el-button>
</el-alert>
<section v-if="loadError" class="surface-panel partners-error" role="alert">
<div>
<strong>往来单位列表暂时无法加载</strong>
@@ -474,11 +503,11 @@ watch(selectedTenantId, (tenantId) => {
<div class="partners-table-header">
<div class="partners-table-heading">
<h2>往来单位列表</h2>
<span>租户 {{ selectedTenantId || '—' }} · 共 {{ total }} 个往来单位</span>
<span>{{ selectedEnterpriseName }} · 共 {{ total }} 个往来单位</span>
</div>
<div class="partners-primary-actions">
<el-button v-if="access.can('platform:counterparty:import')" :disabled="loading || !tenantReady" @click="openImport"><Upload :size="16" />批量导入</el-button>
<el-button v-if="access.can('platform:counterparty:create')" type="primary" :disabled="loading || !tenantReady" @click="openCreate"><Plus :size="16" />新增往来单位</el-button>
<el-button v-if="access.can('platform:counterparty:import')" :disabled="loading" @click="openImport"><Upload :size="16" />批量导入</el-button>
<el-button v-if="access.can('platform:counterparty:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增往来单位</el-button>
</div>
</div>
<DataTable
@@ -496,11 +525,12 @@ watch(selectedTenantId, (tenantId) => {
<template #actions="{ row }">
<div class="table-actions">
<el-button link type="primary" @click="openDetail(row as PlatformPartnerResource)"><Eye :size="15" />详情</el-button>
<el-button v-if="access.can('platform:counterparty:edit')" link type="primary" :disabled="!tenantReady" @click="openEdit(row as PlatformPartnerResource)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="access.can('platform:counterparty:status')" link :disabled="!tenantReady" :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusLoadingIds.has(String((row as PlatformPartnerResource).id))" @click="toggleStatus(row as PlatformPartnerResource)"><Power :size="15" />{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
<el-button v-if="access.can('platform:counterparty:edit')" link type="primary" :disabled="loading" @click="openEdit(row as PlatformPartnerResource)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="access.can('platform:counterparty:status')" link :disabled="loading" :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusLoadingIds.has(String((row as PlatformPartnerResource).id))" @click="toggleStatus(row as PlatformPartnerResource)"><Power :size="15" />{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div>
</template>
<template #partner="{ row }"><div class="partner-cell"><span><Building2 :size="18" /></span><div><strong>{{ (row as PlatformPartnerResource).partnerName }}</strong><small>{{ (row as PlatformPartnerResource).partnerCode }}</small></div></div></template>
<template #tenant="{ row }"><span>{{ tenantName((row as PlatformPartnerResource).tenantId) }}</span></template>
<template #type="{ row }"><div class="partner-types"><el-tag v-for="type in (row as PlatformPartnerResource).types" :key="type" effect="plain">{{ partnerTypeLabel(type) }}</el-tag></div></template>
<template #contact="{ row }"><strong>{{ (row as PlatformPartnerResource).contactName }}</strong><span class="partner-subline">{{ (row as PlatformPartnerResource).contactPhone }}</span><span v-if="(row as PlatformPartnerResource).contactEmail" class="partner-subline">{{ (row as PlatformPartnerResource).contactEmail }}</span></template>
<template #address="{ row }"><div class="partner-address"><MapPin :size="15" /><span>{{ [(row as PlatformPartnerResource).province, (row as PlatformPartnerResource).city, (row as PlatformPartnerResource).district, (row as PlatformPartnerResource).addressLine].filter(Boolean).join(' ') }}</span></div></template>
@@ -51,7 +51,7 @@ export interface PlatformExpressCompanyImportResult {
const basePath = '/platform/v1/express-companies'
export async function listPlatformExpressCompanies(
tenantId: number | string,
tenantId: number | string | undefined,
page = 1,
size = 20,
keyword = '',
@@ -5,8 +5,8 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vites
import { listPlatformExpressCompanies, type PlatformExpressCompanyResource } from '@/modules/master-data/express-companies/api/platformExpressCompanies'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { listPlatformTenants, type PlatformTenantResource } from '../../../iam/api/platformIam'
import PlatformTenantSelector from '../../../iam/components/PlatformTenantSelector.vue'
import ExpressCompaniesView from './ExpressCompaniesView.vue'
vi.mock('@/modules/master-data/express-companies/api/platformExpressCompanies', () => ({
@@ -95,9 +95,12 @@ describe('ExpressCompaniesView query and table migration', () => {
const wrapper = await mountView()
expect(wrapper.find('.query-toolbar').exists()).toBe(true)
expect(wrapper.find('input[aria-label="所属企业"]').exists()).toBe(true)
expect(wrapper.find('.data-table').exists()).toBe(true)
expect(wrapper.text()).toContain('顺丰速运')
expect(wrapper.text()).toContain('全部企业 · 共 1 家快递公司')
expect(wrapper.find('.metric-strip').exists()).toBe(false)
expect(listPlatformExpressCompanies).toHaveBeenCalledWith(undefined, 1, 20, '', '')
})
it('does not request with edited filters until search is submitted', async () => {
@@ -110,13 +113,16 @@ describe('ExpressCompaniesView query and table migration', () => {
await searchButton?.trigger('click')
await flushPromises()
expect(listPlatformExpressCompanies).toHaveBeenLastCalledWith(10001, 1, 20, '顺丰', '')
expect(listPlatformExpressCompanies).toHaveBeenLastCalledWith(undefined, 1, 20, '顺丰', '')
})
it('preserves a snowflake tenant id as a string when switching enterprises', async () => {
const wrapper = await mountView()
wrapper.findComponent(PlatformTenantSelector).vm.$emit('update:modelValue', snowflakeTenantId)
const toolbar = wrapper.findComponent(QueryToolbar)
const model = { tenantId: snowflakeTenantId, keyword: '', status: '' }
toolbar.vm.$emit('update:modelValue', model)
toolbar.vm.$emit('search', model)
await flushPromises()
expect(listPlatformExpressCompanies).toHaveBeenLastCalledWith(snowflakeTenantId, 1, 20, '', '')
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Download, ExternalLink, FileSpreadsheet, Pencil, Plus, Power, Truck, Upload } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -21,7 +21,7 @@ import {
type SavePlatformExpressCompanyRequest,
} from '@/modules/master-data/express-companies/api/platformExpressCompanies'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { PlatformTenantSelector } from '@/modules/iam'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
const access = useAccessPreviewStore()
const loading = ref(false)
@@ -34,8 +34,9 @@ const rows = ref<PlatformExpressCompanyResource[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const selectedTenantId = ref('')
const loadedTenantId = ref<string | null>(null)
const tenants = ref<PlatformTenantResource[]>([])
const tenantLoadError = ref<ApiErrorDetails | null>(null)
const editorTenantId = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const statusLoadingIds = ref(new Set<string>())
const editLoadingIds = ref(new Set<string>())
@@ -45,12 +46,21 @@ const importFile = ref<File | null>(null)
const importResult = ref<PlatformExpressCompanyImportResult | null>(null)
const importError = ref<ApiErrorDetails | null>(null)
type ExpressCompanyFilters = QueryModel & {
tenantId: string
keyword: string
status: PlatformExpressCompanyStatus | ''
}
const filters = ref<ExpressCompanyFilters>({ keyword: '', status: '' })
const filters = ref<ExpressCompanyFilters>({ tenantId: '', keyword: '', status: '' })
const appliedFilters = ref<ExpressCompanyFilters>({ ...filters.value })
const queryFields: QueryField[] = [
const queryFields = computed<QueryField[]>(() => [
{
key: 'tenantId',
type: 'select',
width: 180,
placeholder: tenantLoadError.value ? '企业列表加载失败' : '全部企业',
props: { 'aria-label': '所属企业', filterable: true, disabled: Boolean(tenantLoadError.value) },
options: tenants.value.map((tenant) => ({ value: String(tenant.id), label: `${tenant.tenantName}(${tenant.tenantCode || tenant.id})` })),
},
{ key: 'keyword', type: 'input', placeholder: '快递公司名称或编码', props: { maxlength: 128, 'aria-label': '快递公司关键词' } },
{
key: 'status',
@@ -62,7 +72,7 @@ const queryFields: QueryField[] = [
{ label: '停用', value: 'INACTIVE' },
],
},
]
])
let loadSequence = 0
const emptyForm = (): SavePlatformExpressCompanyRequest & { id: string } => ({
@@ -93,15 +103,12 @@ const { captureBaseline } = useUnsavedWorkRegistration({
requiredPermission: editorPermission,
})
const tenantReady = computed(() => Boolean(selectedTenantId.value)
&& loadedTenantId.value === selectedTenantId.value
&& !loading.value)
const tenantSwitchDisabled = computed(() => editorOpen.value
|| importOpen.value
|| saving.value
|| importing.value
|| editLoadingIds.value.size > 0
|| statusLoadingIds.value.size > 0)
const selectedEnterpriseId = computed(() => appliedFilters.value.tenantId)
const targetEnterpriseId = computed(() => filters.value.tenantId)
const selectedEnterpriseName = computed(() => {
if (!selectedEnterpriseId.value) return '全部企业'
return tenants.value.find((tenant) => String(tenant.id) === selectedEnterpriseId.value)?.tenantName || `企业 ${selectedEnterpriseId.value}`
})
watch(
() => access.can(editorPermission.value),
@@ -135,6 +142,7 @@ function formatUpdatedAt(value?: string | null) {
const expressCompanyColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'company', label: '快递公司', minWidth: 210, slotName: 'company' },
{ key: 'tenant', label: '所属企业', minWidth: 190, showOverflowTooltip: true, slotName: 'tenant' },
{ prop: 'servicePhone', label: '客服电话', minWidth: 130, formatter: (row: PlatformExpressCompanyResource) => row.servicePhone || '—' },
{ key: 'website', label: '官方网站', minWidth: 210, slotName: 'website' },
{ key: 'trackingUrlTemplate', label: '轨迹查询模板', minWidth: 260, slotName: 'trackingUrlTemplate' },
@@ -143,27 +151,50 @@ const expressCompanyColumns: DataTableColumn[] = [
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
]
function apiTenantId(value: string) {
function apiTenantId(value: string | undefined) {
if (!value) return undefined
const numeric = Number(value)
return Number.isSafeInteger(numeric) ? numeric : value
}
function requireCurrentTenant() {
if (!tenantReady.value) {
ElMessage.warning('快递公司数据尚未加载完成,请稍后重试')
function requireSelectedEnterprise(action: string) {
if (!targetEnterpriseId.value) {
ElMessage.warning(`请先在筛选条件中选择所属企业,再${action}`)
return null
}
return apiTenantId(selectedTenantId.value)
return apiTenantId(targetEnterpriseId.value)!
}
async function loadExpressCompanies(tenantId = selectedTenantId.value) {
if (!tenantId) return
function tenantName(tenantId: number | string) {
return tenants.value.find((tenant) => String(tenant.id) === String(tenantId))?.tenantName || `企业 ${tenantId}`
}
async function loadTenants() {
tenantLoadError.value = null
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200 })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
tenants.value = records
} catch (error) {
tenants.value = []
tenantLoadError.value = getApiErrorDetails(error, '企业列表加载失败,暂时无法按所属企业筛选')
}
}
async function loadExpressCompanies() {
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
const result = await listPlatformExpressCompanies(
apiTenantId(tenantId),
apiTenantId(appliedFilters.value.tenantId),
page.value,
pageSize.value,
appliedFilters.value.keyword,
@@ -172,7 +203,6 @@ async function loadExpressCompanies(tenantId = selectedTenantId.value) {
if (sequence !== loadSequence) return
rows.value = result.records
total.value = result.total
loadedTenantId.value = tenantId
} catch (error) {
if (sequence !== loadSequence) return
rows.value = []
@@ -185,6 +215,7 @@ async function loadExpressCompanies(tenantId = selectedTenantId.value) {
function applyFilters(model: QueryModel) {
appliedFilters.value = {
tenantId: String(model.tenantId || ''),
keyword: String(model.keyword || ''),
status: (model.status || '') as PlatformExpressCompanyStatus | '',
}
@@ -196,7 +227,9 @@ function applyFilters(model: QueryModel) {
}
function openCreate() {
if (!requireCurrentTenant()) return
const tenantId = requireSelectedEnterprise('新增快递公司')
if (!tenantId) return
editorTenantId.value = String(tenantId)
Object.assign(form, emptyForm())
captureBaseline()
editorOpen.value = true
@@ -220,8 +253,8 @@ function assignEditor(row: PlatformExpressCompanyResource) {
}
async function openEdit(row: PlatformExpressCompanyResource) {
const tenantId = requireCurrentTenant()
if (!tenantId) return
const tenantId = apiTenantId(String(row.tenantId))!
editorTenantId.value = String(row.tenantId)
const id = String(row.id)
const pending = new Set(editLoadingIds.value)
pending.add(id)
@@ -239,6 +272,7 @@ async function openEdit(row: PlatformExpressCompanyResource) {
}
function openImport() {
if (!requireSelectedEnterprise('批量导入')) return
importFile.value = null
importResult.value = null
importError.value = null
@@ -292,7 +326,7 @@ function selectImportFile(event: Event) {
}
async function submitImport() {
const tenantId = requireCurrentTenant()
const tenantId = requireSelectedEnterprise('批量导入')
if (!tenantId) return
if (!importFile.value) {
ElMessage.warning('请先选择填写完成的 XLSX 文件')
@@ -340,7 +374,7 @@ function buildPayload(): SavePlatformExpressCompanyRequest {
}
async function saveExpressCompany() {
const tenantId = requireCurrentTenant()
const tenantId = form.id ? apiTenantId(editorTenantId.value) : requireSelectedEnterprise('新增快递公司')
if (!tenantId) return
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
@@ -361,8 +395,7 @@ async function saveExpressCompany() {
}
async function toggleStatus(row: PlatformExpressCompanyResource) {
const tenantId = requireCurrentTenant()
if (!tenantId) return
const tenantId = apiTenantId(String(row.tenantId))!
const nextStatus: PlatformExpressCompanyStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
const action = nextStatus === 'ACTIVE' ? '启用' : '停用'
try {
@@ -393,23 +426,20 @@ async function toggleStatus(row: PlatformExpressCompanyResource) {
}
watch([page, pageSize], () => { void loadExpressCompanies() })
watch(selectedTenantId, (tenantId) => {
editorOpen.value = false
importOpen.value = false
importFile.value = null
importResult.value = null
importError.value = null
rows.value = []
total.value = 0
loadedTenantId.value = null
page.value = 1
void loadExpressCompanies(tenantId)
onMounted(() => {
void loadTenants()
void loadExpressCompanies()
})
</script>
<template>
<div class="business-page express-company-page">
<PlatformTenantSelector v-model="selectedTenantId" :disabled="tenantSwitchDisabled" />
<el-alert v-if="tenantLoadError" type="warning" :closable="false" show-icon class="state-alert">
<template #title>所属企业列表加载失败</template>
<p>{{ tenantLoadError.message }};快递公司仍按全部企业展示。</p>
<ApiErrorMeta :details="tenantLoadError" />
<el-button size="small" @click="loadTenants">重新加载企业列表</el-button>
</el-alert>
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
<template #title>快递公司加载失败</template>
<p>{{ loadError.message }}</p>
@@ -423,11 +453,11 @@ watch(selectedTenantId, (tenantId) => {
<div class="panel-heading">
<div>
<h2>快递公司列表</h2>
<p>租户 {{ selectedTenantId || '—' }} · 共 {{ total }} 家快递公司</p>
<p>{{ selectedEnterpriseName }} · 共 {{ total }} 家快递公司</p>
</div>
<div class="panel-actions">
<el-button v-if="access.can('platform:express:import')" :disabled="!tenantReady" @click="openImport"><Upload :size="16" />批量导入</el-button>
<el-button v-if="access.can('platform:express:create')" type="primary" :disabled="!tenantReady" @click="openCreate"><Plus :size="16" />新增快递公司</el-button>
<el-button v-if="access.can('platform:express:import')" :disabled="loading" @click="openImport"><Upload :size="16" />批量导入</el-button>
<el-button v-if="access.can('platform:express:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增快递公司</el-button>
</div>
</div>
<DataTable
@@ -448,14 +478,14 @@ watch(selectedTenantId, (tenantId) => {
v-if="access.can('platform:express:edit')"
link
type="primary"
:disabled="!tenantReady"
:disabled="loading"
:loading="editLoadingIds.has(String((row as PlatformExpressCompanyResource).id))"
@click="openEdit(row as PlatformExpressCompanyResource)"
><Pencil :size="15" />编辑</el-button>
<el-button
v-if="access.can('platform:express:status')"
link
:disabled="!tenantReady"
:disabled="loading"
:type="(row as PlatformExpressCompanyResource).status === 'ACTIVE' ? 'danger' : 'success'"
:loading="statusLoadingIds.has(String((row as PlatformExpressCompanyResource).id))"
@click="toggleStatus(row as PlatformExpressCompanyResource)"
@@ -464,6 +494,7 @@ watch(selectedTenantId, (tenantId) => {
<span v-else class="muted-value">仅查看</span>
</template>
<template #company="{ row }"><div class="express-company-cell"><span><Truck :size="17" /></span><div><strong>{{ (row as PlatformExpressCompanyResource).companyName }}</strong><small>{{ (row as PlatformExpressCompanyResource).companyCode }}</small></div></div></template>
<template #tenant="{ row }"><span>{{ tenantName((row as PlatformExpressCompanyResource).tenantId) }}</span></template>
<template #website="{ row }">
<a v-if="safeHttpUrl((row as PlatformExpressCompanyResource).website)" class="external-link" :href="safeHttpUrl((row as PlatformExpressCompanyResource).website)!" target="_blank" rel="noopener noreferrer">访问官网<ExternalLink :size="13" /></a>
<span v-else class="muted-value">{{ (row as PlatformExpressCompanyResource).website || '—' }}</span>
+1
View File
@@ -0,0 +1 @@
export * from './counterparties'
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
changePlatformLocationStatus,
createPlatformLocation,
deletePlatformLocation,
getPlatformLocation,
listPlatformLocations,
updatePlatformLocation,
type SavePlatformLocationRequest,
} from './platformLocations'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
const payload: SavePlatformLocationRequest = {
warehouseId: '190000000000000101',
zoneCode: 'A',
zoneName: '常规区',
locationCode: 'A1-01',
locationName: 'A1 区 01 位',
locationType: 'SHELF',
maxWeightKg: 500,
status: 'AVAILABLE',
remark: null,
version: 3,
}
describe('platform location API', () => {
beforeEach(() => {
vi.mocked(http.get).mockReset()
vi.mocked(http.post).mockReset()
vi.mocked(http.put).mockReset()
vi.mocked(http.patch).mockReset()
vi.mocked(http.delete).mockReset()
})
it('uses the implemented paged list and detail contracts', async () => {
const page = { records: [], page: '2', size: '20', total: '21', pages: '2' }
const detail = { location: { id: '19001' }, warehouseCode: 'CN-YW-01', warehouseName: '义乌仓' }
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', message: 'success', data: page })
.mockResolvedValueOnce({ code: '200', message: 'success', data: detail })
await expect(listPlatformLocations({
page: 2,
size: 20,
tenantId: '9223372036854775806',
warehouseId: '190000000000000101',
zoneCode: ' A ',
locationCode: ' A1 ',
locationType: 'SHELF',
status: 'AVAILABLE',
})).resolves.toEqual({ records: [], page: 2, size: 20, total: 21, pages: 2 })
await expect(getPlatformLocation('9223372036854775806', '190000000000000101', '19001')).resolves.toEqual(detail)
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/locations', {
params: {
tenantId: '9223372036854775806', page: 2, size: 20, warehouseId: '190000000000000101',
zoneCode: 'A', locationCode: 'A1', locationType: 'SHELF', status: 'AVAILABLE',
},
})
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/locations/19001', {
params: { tenantId: '9223372036854775806', warehouseId: '190000000000000101' },
})
})
it('omits empty customer, warehouse, and filter values from all-customer list requests', async () => {
const page = { records: [], page: 1, size: 20, total: 0, pages: 0 }
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: page })
await expect(listPlatformLocations({
page: 1,
size: 20,
tenantId: '',
warehouseId: '',
zoneCode: '',
locationCode: '',
locationType: '',
status: '',
})).resolves.toEqual(page)
expect(http.get).toHaveBeenCalledWith('/platform/v1/locations', {
params: { page: 1, size: 20 },
})
})
it('sends versioned create, update, status, and delete requests', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: '19001' })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null })
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await expect(createPlatformLocation(10001, payload)).resolves.toBe('19001')
await expect(updatePlatformLocation(10001, payload.warehouseId, '19001', payload)).resolves.toBeNull()
await expect(changePlatformLocationStatus(10001, payload.warehouseId, '19001', 'DISABLED', 3)).resolves.toBeNull()
await expect(deletePlatformLocation(10001, payload.warehouseId, '19001', 4)).resolves.toBeNull()
expect(http.post).toHaveBeenCalledWith('/platform/v1/locations', payload, { params: { tenantId: 10001 } })
expect(http.put).toHaveBeenCalledWith('/platform/v1/locations/19001', payload, {
params: { tenantId: 10001, warehouseId: payload.warehouseId },
})
expect(http.patch).toHaveBeenCalledWith('/platform/v1/locations/19001/status', { status: 'DISABLED', version: 3 }, {
params: { tenantId: 10001, warehouseId: payload.warehouseId },
})
expect(http.delete).toHaveBeenCalledWith('/platform/v1/locations/19001', {
params: { tenantId: 10001, warehouseId: payload.warehouseId, version: 4 },
})
})
})
@@ -0,0 +1,148 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type PlatformLocationStatus = 'AVAILABLE' | 'RESERVED' | 'OCCUPIED' | 'DISABLED'
export type EditablePlatformLocationStatus = 'AVAILABLE' | 'DISABLED'
export type PlatformLocationType = 'SHELF' | 'PALLET' | 'FLOOR' | 'FLOW_RACK'
export interface PlatformLocationResource {
id: number | string
tenantId: number | string
warehouseId: number | string
warehouseCode?: string | null
warehouseName?: string | null
zoneCode: string
zoneName: string
locationCode: string
locationName: string | null
locationType: PlatformLocationType
maxWeightKg: number | string | null
sharedOverflow: boolean | null
status: PlatformLocationStatus
remark: string | null
version: number
createdAt?: string | null
updatedAt?: string | null
}
export interface PlatformLocationDetail {
location: PlatformLocationResource
warehouseCode: string
warehouseName: string
}
export interface PlatformLocationPage {
records: PlatformLocationResource[]
page: number
size: number
total: number
pages: number
}
export interface PlatformLocationQuery {
page?: number
size?: number
tenantId?: number | string
warehouseId?: number | string
zoneCode?: string
locationCode?: string
locationType?: PlatformLocationType | ''
status?: PlatformLocationStatus | ''
}
export interface SavePlatformLocationRequest {
warehouseId: number | string
zoneCode: string
zoneName: string
locationCode: string
locationName: string | null
locationType: PlatformLocationType
maxWeightKg: number | null
status: EditablePlatformLocationStatus
remark: string | null
version?: number
}
const basePath = '/platform/v1/locations'
export async function listPlatformLocations(query: PlatformLocationQuery) {
const zoneCode = query.zoneCode?.trim()
const locationCode = query.locationCode?.trim()
const response = await http.get<ApiEnvelope<PlatformLocationPage>, ApiEnvelope<PlatformLocationPage>>(basePath, {
params: {
page: query.page ?? 1,
size: query.size ?? 20,
...(query.tenantId ? { tenantId: query.tenantId } : {}),
...(query.warehouseId ? { warehouseId: query.warehouseId } : {}),
...(zoneCode ? { zoneCode } : {}),
...(locationCode ? { locationCode } : {}),
...(query.locationType ? { locationType: query.locationType } : {}),
...(query.status ? { status: query.status } : {}),
},
})
const result = unwrapApiResponse(response)
return {
...result,
page: Number(result.page),
size: Number(result.size),
total: Number(result.total),
pages: Number(result.pages),
}
}
export async function getPlatformLocation(
tenantId: number | string,
warehouseId: number | string,
id: number | string,
) {
const response = await http.get<ApiEnvelope<PlatformLocationDetail>, ApiEnvelope<PlatformLocationDetail>>(
`${basePath}/${id}`,
{ params: { tenantId, warehouseId } },
)
return unwrapApiResponse(response)
}
export async function createPlatformLocation(tenantId: number | string, payload: SavePlatformLocationRequest) {
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, payload, {
params: { tenantId },
})
return unwrapApiResponse(response)
}
export async function updatePlatformLocation(
tenantId: number | string,
warehouseId: number | string,
id: number | string,
payload: SavePlatformLocationRequest,
) {
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, payload, {
params: { tenantId, warehouseId },
})
return unwrapApiResponse(response)
}
export async function changePlatformLocationStatus(
tenantId: number | string,
warehouseId: number | string,
id: number | string,
status: EditablePlatformLocationStatus,
version: number,
) {
const response = await http.patch<ApiEnvelope<null>, ApiEnvelope<null>>(
`${basePath}/${id}/status`,
{ status, version },
{ params: { tenantId, warehouseId } },
)
return unwrapApiResponse(response)
}
export async function deletePlatformLocation(
tenantId: number | string,
warehouseId: number | string,
id: number | string,
version: number,
) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, {
params: { tenantId, warehouseId, version },
})
return unwrapApiResponse(response)
}
@@ -0,0 +1,310 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import {
getPlatformLocation,
listPlatformLocations,
type PlatformLocationResource,
} from '../api/platformLocations'
import { listPlatformTenants, type PlatformTenantResource } from '../../../iam/api/platformIam'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import LocationsView from './LocationsView.vue'
vi.mock('../../../iam/api/platformIam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../../../warehouses/api/platformWarehouses', () => ({ listPlatformWarehouses: vi.fn() }))
vi.mock('../api/platformLocations', () => ({
listPlatformLocations: vi.fn(),
getPlatformLocation: vi.fn(),
createPlatformLocation: vi.fn(),
updatePlatformLocation: vi.fn(),
changePlatformLocationStatus: vi.fn(),
deletePlatformLocation: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size },
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
}
}
const tenant = (id: number | string): PlatformTenantResource => ({
id,
tenantCode: String(id),
tenantName: '雪花企业',
status: 'ACTIVE',
legalName: '雪花企业',
legalRepresentative: null,
unifiedSocialCreditCode: null,
contactName: null,
contactPhone: null,
contactEmail: null,
servicePackage: null,
contactWechat: null,
salesOwnerName: null,
paymentTermDays: null,
enterpriseType: null,
establishedDate: null,
registeredCapital: null,
registeredCapitalCurrency: null,
registeredAddress: null,
internalRemark: null,
createdAt: null,
approvedAt: null,
})
const tenantId = '9223372036854775806'
const warehouseId = '9223372036854775805'
const warehouse: PlatformWarehouseResource = {
id: warehouseId,
tenantId,
warehouseCode: 'CN-YW-01',
warehouseName: '义乌收货仓',
warehouseType: 'DOMESTIC',
countryCode: 'CN',
timezone: 'Asia/Shanghai',
contactName: null,
contactPhone: null,
contactEmail: null,
status: 'ACTIVE',
remark: null,
}
const availableLocation: PlatformLocationResource = {
id: '9223372036854775804',
tenantId,
warehouseId,
zoneCode: 'A',
zoneName: '常规区',
locationCode: 'A1-01',
locationName: 'A1 区 01 位',
locationType: 'SHELF',
maxWeightKg: '500.000',
sharedOverflow: false,
status: 'AVAILABLE',
remark: '靠近入库口',
version: 3,
updatedAt: '2026-08-04T10:20:00',
}
const occupiedLocation: PlatformLocationResource = {
...availableLocation,
id: '9223372036854775803',
locationCode: 'A1-02',
locationName: 'A1 区 02 位',
status: 'OCCUPIED',
version: 5,
}
async function mountView() {
vi.stubGlobal('localStorage', createMemoryStorage())
vi.stubGlobal('sessionStorage', createMemoryStorage())
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = [
'platform:location:list',
'platform:location:create',
'platform:location:edit',
'platform:location:status',
'platform:location:delete',
]
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(LocationsView, {
attachTo: document.body,
global: { plugins: [pinia, ElementPlus] },
})
await flushPromises()
return wrapper
}
async function selectScope(wrapper: VueWrapper) {
let toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { ...(toolbar.props('modelValue') as QueryModel), customerId: tenantId })
await flushPromises()
toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { ...(toolbar.props('modelValue') as QueryModel), warehouseId })
await flushPromises()
const searchButton = toolbar.findAll('button').find((button) => button.text().trim() === '查询')
await searchButton!.trigger('click')
await flushPromises()
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('LocationsView', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
document.body.innerHTML = ''
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({
records: [tenant(tenantId)], page: 1, size: 200, total: 1, pages: 1,
})
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({
records: [warehouse], page: 1, size: 200, total: 1, pages: 1,
})
vi.mocked(listPlatformLocations).mockReset().mockResolvedValue({
records: [availableLocation, occupiedLocation], page: 1, size: 20, total: 2, pages: 1,
})
vi.mocked(getPlatformLocation).mockReset().mockResolvedValue({
location: availableLocation, warehouseCode: warehouse.warehouseCode, warehouseName: warehouse.warehouseName,
})
})
it('starts with all customers and queries the full list without empty IDs', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
const fields = toolbar.props('fields') as QueryField[]
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
expect(wrapper.find('[aria-label="所属企业"]').exists()).toBe(true)
expect(fields[0]?.placeholder).toBe('全部客户')
expect((toolbar.props('modelValue') as QueryModel).customerId).toBe('')
expect(fields.map(({ key, width }) => [key, width])).toEqual([
['customerId', 260], ['warehouseId', 240], ['zoneCode', 210],
['locationCode', 220], ['locationType', 170], ['status', 160],
])
expect(listPlatformWarehouses).not.toHaveBeenCalled()
expect(listPlatformLocations).toHaveBeenCalledWith({
page: 1,
size: 20,
zoneCode: '',
locationCode: '',
locationType: '',
status: '',
})
const searchButton = toolbar.findAll('button').find((button) => button.text().trim() === '查询')
await searchButton!.trigger('click')
await flushPromises()
expect(listPlatformLocations).toHaveBeenCalledTimes(2)
expect(wrapper.text()).not.toContain('请选择客户')
expect(wrapper.text()).not.toContain('请选择所属企业')
expect(wrapper.text()).not.toContain('租户')
expect(wrapper.text()).not.toContain('企业 ID')
})
it('loads the selected customer, warehouse, and location hierarchy without narrowing snowflake IDs', async () => {
const wrapper = await mountView()
await selectScope(wrapper)
expect(wrapper.find('.table-panel .panel-heading').exists()).toBe(false)
expect(listPlatformWarehouses).toHaveBeenCalledWith(tenantId, 1, 200)
expect(listPlatformLocations).toHaveBeenLastCalledWith({
page: 1,
size: 20,
tenantId,
warehouseId,
zoneCode: '',
locationCode: '',
locationType: '',
status: '',
})
expect(wrapper.text()).toContain('义乌收货仓')
expect(wrapper.text()).toContain('A1-01')
expect(wrapper.findAll('thead th').map((item) => item.text().trim())).toEqual([
'操作', '库位编码', '所属仓库', '库区', '库位类型', '最大承重(kg)', '状态', '备注',
])
})
it('keeps the operation column first and hides edit actions for occupied resources', async () => {
const wrapper = await mountView()
await selectScope(wrapper)
const headings = wrapper.findAll('thead th').map((item) => item.text().trim())
expect(headings[0]).toBe('操作')
const bodyRows = wrapper.findAll('tbody tr')
expect(bodyRows[0]?.text()).toContain('编辑')
expect(bodyRows[1]?.text()).toContain('详情')
expect(bodyRows[1]?.text()).not.toContain('编辑')
expect(bodyRows[1]?.text()).not.toContain('更多')
})
it('opens an authenticated detail view with warehouse context', async () => {
const wrapper = await mountView()
const detailButton = wrapper.find('.el-table__body-wrapper tbody tr button')
expect(detailButton.text()).toBe('详情')
await detailButton.trigger('click')
await flushPromises()
expect(getPlatformLocation).toHaveBeenCalledWith(tenantId, warehouseId, availableLocation.id)
expect(document.body.textContent).toContain('库位详情')
expect(document.body.textContent).toContain('义乌收货仓(CN-YW-01)')
expect(document.body.textContent).toContain(`所属企业雪花企业(${tenantId})`)
expect(document.body.textContent).not.toContain('企业 ID')
expect(document.body.textContent).toContain('库位名称A1 区 01 位')
expect(document.body.textContent).toContain('v3')
})
it('presents occupied locations as read-only business resources', async () => {
vi.mocked(getPlatformLocation).mockResolvedValueOnce({
location: occupiedLocation,
warehouseCode: warehouse.warehouseCode,
warehouseName: warehouse.warehouseName,
})
const wrapper = await mountView()
const occupiedRow = wrapper.findAll('tbody tr')[1]
await occupiedRow!.find('button').trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('状态已占用')
expect(document.body.textContent).toContain('当前库位正在被业务使用,只允许查看。')
})
it('clears customer and warehouse on reset and immediately restores the full query', async () => {
const wrapper = await mountView()
await selectScope(wrapper)
const toolbar = wrapper.findComponent(QueryToolbar)
const resetButton = toolbar.findAll('button').find((button) => button.text().trim() === '重置')
await resetButton!.trigger('click')
await flushPromises()
const model = wrapper.findComponent(QueryToolbar).props('modelValue') as QueryModel
expect(model.customerId).toBe('')
expect(model.warehouseId).toBe('')
expect(listPlatformLocations).toHaveBeenLastCalledWith({
page: 1,
size: 20,
zoneCode: '',
locationCode: '',
locationType: '',
status: '',
})
expect(wrapper.text()).not.toContain('请选择客户')
})
it('paginates the all-customer list without adding customer or warehouse IDs', async () => {
vi.mocked(listPlatformLocations).mockResolvedValue({
records: [availableLocation, occupiedLocation], page: 1, size: 20, total: 40, pages: 2,
})
const wrapper = await mountView()
wrapper.findComponent(DataTable).vm.$emit('update:page', 2)
await flushPromises()
expect(listPlatformLocations).toHaveBeenLastCalledWith({
page: 2,
size: 20,
zoneCode: '',
locationCode: '',
locationType: '',
status: '',
})
})
})
@@ -0,0 +1,808 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Eye, MapPin, MoreHorizontal, Pencil, Plus, Power, Trash2 } from '@lucide/vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import {
changePlatformLocationStatus,
createPlatformLocation,
deletePlatformLocation,
getPlatformLocation,
listPlatformLocations,
updatePlatformLocation,
type EditablePlatformLocationStatus,
type PlatformLocationDetail,
type PlatformLocationQuery,
type PlatformLocationResource,
type PlatformLocationStatus,
type PlatformLocationType,
type SavePlatformLocationRequest,
} from '../api/platformLocations'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
type DrawerMode = 'view' | 'create' | 'edit'
type LocationFilters = QueryModel & {
customerId: string
warehouseId: string
zoneCode: string
locationCode: string
locationType: PlatformLocationType | ''
status: PlatformLocationStatus | ''
}
type AppliedLocationFilters = {
zoneCode: string
locationCode: string
locationType: PlatformLocationType | ''
status: PlatformLocationStatus | ''
}
const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const customerLoading = ref(false)
const customerError = ref<ApiErrorDetails | null>(null)
const loadedCustomerId = ref<string | null>(null)
const warehouses = ref<PlatformWarehouseResource[]>([])
const warehouseLoading = ref(false)
const warehouseError = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const rows = ref<PlatformLocationResource[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const drawerOpen = ref(false)
const drawerMode = ref<DrawerMode>('view')
const drawerLoading = ref(false)
const drawerError = ref<ApiErrorDetails | null>(null)
const detail = ref<PlatformLocationDetail | null>(null)
const saving = ref(false)
const actionLoadingIds = ref(new Set<string>())
const formRef = ref<FormInstance>()
let customerLoadSequence = 0
let locationLoadSequence = 0
const filters = ref<LocationFilters>({ customerId: '', warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' })
const appliedFilters = ref<AppliedLocationFilters>({ zoneCode: '', locationCode: '', locationType: '', status: '' })
const selectedCustomerId = computed(() => String(filters.value.customerId || ''))
const selectedWarehouseId = computed(() => String(filters.value.warehouseId || ''))
const queryFields = computed<QueryField[]>(() => [
{
key: 'customerId',
type: 'select',
width: 260,
placeholder: customerError.value ? '客户列表加载失败' : '全部客户',
props: { 'aria-label': '所属企业', filterable: true, disabled: customerLoading.value || Boolean(customerError.value) || selectionDisabled.value },
options: customers.value.map((customer) => ({
value: String(customer.id),
label: `${customer.tenantName}(${customer.tenantCode || customer.id})`,
})),
},
{
key: 'warehouseId',
type: 'select',
width: 240,
placeholder: warehouseLoading.value ? '仓库加载中' : '所属仓库',
props: {
'aria-label': '所属仓库',
filterable: true,
disabled: selectionDisabled.value || warehouseLoading.value || !customerReady.value || warehouses.value.length === 0,
},
options: warehouses.value.map((warehouse) => ({
value: String(warehouse.id),
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
disabled: warehouse.status !== 'ACTIVE',
})),
},
{ key: 'zoneCode', type: 'input', width: 210, placeholder: '库区编码', props: { maxlength: 50, 'aria-label': '库区编码' } },
{ key: 'locationCode', type: 'input', width: 220, placeholder: '库位编码', props: { maxlength: 50, 'aria-label': '库位编码' } },
{
key: 'locationType', type: 'select', width: 170, placeholder: '全部类型', props: { 'aria-label': '库位类型' },
options: [
{ label: '货架位', value: 'SHELF' },
{ label: '托盘位', value: 'PALLET' },
{ label: '地堆位', value: 'FLOOR' },
{ label: '流利架', value: 'FLOW_RACK' },
],
},
{
key: 'status', type: 'select', width: 160, placeholder: '全部状态', props: { 'aria-label': '库位状态' },
options: [
{ label: '可用', value: 'AVAILABLE' },
{ label: '已预占', value: 'RESERVED' },
{ label: '已占用', value: 'OCCUPIED' },
{ label: '已停用', value: 'DISABLED' },
],
},
])
const emptyForm = () => ({
id: '',
customerId: selectedCustomerId.value,
warehouseId: selectedWarehouseId.value,
zoneCode: '',
zoneName: '',
locationCode: '',
locationName: '',
locationType: 'SHELF' as PlatformLocationType,
maxWeightKg: null as number | null,
status: 'AVAILABLE' as EditablePlatformLocationStatus,
remark: '',
version: 0,
})
const form = reactive(emptyForm())
const rules: FormRules<typeof form> = {
zoneCode: [{ required: true, message: '请输入库区编码', trigger: 'blur' }],
zoneName: [{ required: true, message: '请输入库区名称', trigger: 'blur' }],
locationCode: [
{ required: true, message: '请输入库位编码', trigger: 'blur' },
{
validator: (_rule, value: string, callback) => {
if (value.trim().toUpperCase() === 'A0') callback(new Error('A0 是系统共享溢出位保留编码'))
else callback()
},
trigger: 'blur',
},
],
locationType: [{ required: true, message: '请选择库位类型', trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
const selectedWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === selectedWarehouseId.value) ?? null)
const customerReady = computed(() => Boolean(selectedCustomerId.value) && loadedCustomerId.value === selectedCustomerId.value && !warehouseLoading.value)
const createReady = computed(() => customerReady.value && Boolean(selectedWarehouse.value) && !loading.value)
const editorOpen = computed(() => drawerOpen.value && drawerMode.value !== 'view')
const editorTitle = computed(() => drawerMode.value === 'create' ? '新增库位' : '编辑库位')
const drawerTitle = computed(() => drawerMode.value === 'view' ? '库位详情' : editorTitle.value)
const editorPermission = computed(() => drawerMode.value === 'create' ? 'platform:location:create' : 'platform:location:edit')
const selectionDisabled = computed(() => drawerOpen.value || saving.value || actionLoadingIds.value.size > 0)
const filtered = computed(() => Object.values(appliedFilters.value).some((value) => String(value || '').trim()))
const emptyDescription = computed(() => filtered.value ? '暂无符合筛选条件的库位' : '暂无库位数据')
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'location-editor',
label: editorTitle,
isOpen: editorOpen,
model: form,
requiredPermission: editorPermission,
})
const statusMeta: Record<PlatformLocationStatus, { label: string; type: 'success' | 'warning' | 'danger' | 'info' }> = {
AVAILABLE: { label: '可用', type: 'success' },
RESERVED: { label: '已预占', type: 'warning' },
OCCUPIED: { label: '已占用', type: 'danger' },
DISABLED: { label: '已停用', type: 'info' },
}
const typeLabels: Record<PlatformLocationType, string> = {
SHELF: '货架位',
PALLET: '托盘位',
FLOOR: '地堆位',
FLOW_RACK: '流利架',
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ key: 'location', label: '库位编码', minWidth: 190, slotName: 'location' },
{ key: 'warehouse', label: '所属仓库', minWidth: 210, slotName: 'warehouse' },
{ key: 'zone', label: '库区', minWidth: 150, slotName: 'zone' },
{ key: 'locationType', label: '库位类型', width: 120, slotName: 'locationType' },
{ key: 'maxWeightKg', label: '最大承重(kg)', minWidth: 140, slotName: 'maxWeightKg' },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'remark', label: '备注', minWidth: 220, slotName: 'remark' },
]
function apiId(value: string) {
return value
}
function optionalText(value: string) {
return value.trim() || null
}
function formatDateTime(value?: string | null) {
return value?.replace('T', ' ').slice(0, 16) || '—'
}
function formatWeight(value: number | string | null) {
if (value === null || value === '') return '—'
const numeric = Number(value)
return Number.isFinite(numeric) ? `${numeric.toLocaleString('zh-CN', { maximumFractionDigits: 3 })} kg` : `${value} kg`
}
function canEdit(row: PlatformLocationResource) {
return access.can('platform:location:edit')
&& !row.sharedOverflow
&& (row.status === 'AVAILABLE' || row.status === 'DISABLED')
}
function canChangeStatus(row: PlatformLocationResource) {
return access.can('platform:location:status')
&& !row.sharedOverflow
&& (row.status === 'AVAILABLE' || row.status === 'DISABLED')
}
function canDelete(row: PlatformLocationResource) {
return access.can('platform:location:delete') && !row.sharedOverflow && row.status === 'DISABLED'
}
function actionError(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
}
function requireCreateScope() {
if (!createReady.value || !selectedCustomerId.value || !selectedWarehouseId.value) {
ElMessage.warning('所属企业和仓库数据尚未加载完成,请稍后重试')
return null
}
return { customerId: apiId(selectedCustomerId.value), warehouseId: apiId(selectedWarehouseId.value) }
}
function recordScope(row: PlatformLocationResource) {
return { customerId: apiId(String(row.tenantId)), warehouseId: apiId(String(row.warehouseId)) }
}
function customerLabel(customerId: number | string) {
const customer = customers.value.find((item) => String(item.id) === String(customerId))
return customer ? `${customer.tenantName}(${customer.tenantCode || customer.id})` : String(customerId)
}
async function loadCustomers() {
customerLoading.value = true
customerError.value = null
try {
const result = await listPlatformTenants({ page: 1, size: 200 })
customers.value = result.records
} catch (error) {
customers.value = []
customerError.value = getApiErrorDetails(error, '所属企业列表加载失败,请稍后重试')
} finally {
customerLoading.value = false
}
}
async function loadWarehouses(customerId = selectedCustomerId.value) {
if (!customerId) return
const sequence = ++customerLoadSequence
warehouseLoading.value = true
warehouseError.value = null
filters.value = { ...filters.value, warehouseId: '' }
warehouses.value = []
try {
const result = await listPlatformWarehouses(apiId(customerId), 1, 200)
if (sequence !== customerLoadSequence) return
warehouses.value = result.records
loadedCustomerId.value = customerId
} catch (error) {
if (sequence !== customerLoadSequence) return
loadedCustomerId.value = null
warehouseError.value = getApiErrorDetails(error, '仓库列表加载失败,请稍后重试')
} finally {
if (sequence === customerLoadSequence) warehouseLoading.value = false
}
}
async function loadLocations() {
const sequence = ++locationLoadSequence
loading.value = true
loadError.value = null
try {
const query: PlatformLocationQuery = {
page: page.value,
size: pageSize.value,
zoneCode: appliedFilters.value.zoneCode,
locationCode: appliedFilters.value.locationCode,
locationType: appliedFilters.value.locationType,
status: appliedFilters.value.status,
}
if (selectedCustomerId.value) query.tenantId = apiId(selectedCustomerId.value)
if (selectedWarehouseId.value) query.warehouseId = apiId(selectedWarehouseId.value)
const result = await listPlatformLocations(query)
if (sequence !== locationLoadSequence) return
rows.value = result.records
total.value = result.total
} catch (error) {
if (sequence !== locationLoadSequence) return
rows.value = []
total.value = 0
loadError.value = getApiErrorDetails(error, '库位列表加载失败,请稍后重试')
} finally {
if (sequence === locationLoadSequence) loading.value = false
}
}
function applyFilters(model: QueryModel) {
appliedFilters.value = {
zoneCode: String(model.zoneCode || ''),
locationCode: String(model.locationCode || ''),
locationType: (model.locationType || '') as PlatformLocationType | '',
status: (model.status || '') as PlatformLocationStatus | '',
}
if (page.value === 1) void loadLocations()
else page.value = 1
}
function assignForm(resource: PlatformLocationResource) {
Object.assign(form, {
id: String(resource.id),
customerId: String(resource.tenantId),
warehouseId: String(resource.warehouseId),
zoneCode: resource.zoneCode,
zoneName: resource.zoneName,
locationCode: resource.locationCode,
locationName: resource.locationName ?? '',
locationType: resource.locationType,
maxWeightKg: resource.maxWeightKg === null ? null : Number(resource.maxWeightKg),
status: resource.status as EditablePlatformLocationStatus,
remark: resource.remark ?? '',
version: resource.version,
})
}
function openCreate() {
if (!requireCreateScope()) return
Object.assign(form, emptyForm(), {
status: selectedWarehouse.value?.status === 'ACTIVE' ? 'AVAILABLE' : 'DISABLED',
})
detail.value = null
drawerError.value = null
drawerMode.value = 'create'
captureBaseline()
drawerOpen.value = true
void formRef.value?.clearValidate()
}
async function loadDetail(row: PlatformLocationResource, mode: 'view' | 'edit') {
const scope = recordScope(row)
drawerMode.value = mode
drawerOpen.value = true
drawerLoading.value = true
drawerError.value = null
detail.value = null
try {
const result = await getPlatformLocation(scope.customerId, scope.warehouseId, row.id)
detail.value = result
if (mode === 'edit') {
assignForm(result.location)
captureBaseline()
void formRef.value?.clearValidate()
}
} catch (error) {
drawerError.value = getApiErrorDetails(error, '库位详情加载失败,请稍后重试')
} finally {
drawerLoading.value = false
}
}
function buildPayload(): SavePlatformLocationRequest {
return {
warehouseId: apiId(form.warehouseId),
zoneCode: form.zoneCode.trim(),
zoneName: form.zoneName.trim(),
locationCode: form.locationCode.trim(),
locationName: optionalText(form.locationName),
locationType: form.locationType,
maxWeightKg: form.maxWeightKg,
status: form.status,
remark: optionalText(form.remark),
...(form.id ? { version: form.version } : {}),
}
}
async function saveLocation() {
const scope = form.id
? { customerId: apiId(form.customerId), warehouseId: apiId(form.warehouseId) }
: requireCreateScope()
if (!scope) return
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const payload = buildPayload()
if (form.id) await updatePlatformLocation(scope.customerId, scope.warehouseId, form.id, payload)
else await createPlatformLocation(scope.customerId, payload)
drawerOpen.value = false
ElMessage.success(form.id ? '库位资料已更新' : '库位已创建')
await loadLocations()
} catch (error) {
actionError(error, '库位保存失败,请检查后重试')
if (form.id) await loadLocations()
} finally {
saving.value = false
}
}
function setActionLoading(id: number | string, active: boolean) {
const next = new Set(actionLoadingIds.value)
if (active) next.add(String(id))
else next.delete(String(id))
actionLoadingIds.value = next
}
async function toggleStatus(row: PlatformLocationResource) {
const scope = recordScope(row)
if (!canChangeStatus(row)) return
const nextStatus: EditablePlatformLocationStatus = row.status === 'AVAILABLE' ? 'DISABLED' : 'AVAILABLE'
const action = nextStatus === 'AVAILABLE' ? '启用' : '停用'
try {
await ElMessageBox.confirm(
nextStatus === 'DISABLED'
? `确定停用“${row.locationCode}”吗?停用前后端会再次检查是否存在活动预占或占用。`
: `确定重新启用“${row.locationCode}”吗?仅启用仓库中的库位可以恢复可用。`,
`${action}库位`,
{ type: nextStatus === 'AVAILABLE' ? 'success' : 'warning', confirmButtonText: `确认${action}`, cancelButtonText: '取消' },
)
} catch {
return
}
setActionLoading(row.id, true)
try {
await changePlatformLocationStatus(scope.customerId, scope.warehouseId, row.id, nextStatus, row.version)
ElMessage.success(`库位 ${row.locationCode} 已${action}`)
await loadLocations()
} catch (error) {
actionError(error, `${action}库位失败`)
await loadLocations()
} finally {
setActionLoading(row.id, false)
}
}
async function removeLocation(row: PlatformLocationResource) {
const scope = recordScope(row)
if (!canDelete(row)) return
try {
await ElMessageBox.confirm(
`确定删除已停用库位“${row.locationCode}”吗?系统会保留审计记录,此操作不能直接恢复。`,
'删除库位',
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' },
)
} catch {
return
}
setActionLoading(row.id, true)
try {
await deletePlatformLocation(scope.customerId, scope.warehouseId, row.id, row.version)
ElMessage.success(`库位 ${row.locationCode} 已删除`)
await loadLocations()
} catch (error) {
actionError(error, '删除库位失败')
await loadLocations()
} finally {
setActionLoading(row.id, false)
}
}
function handleMoreAction(row: PlatformLocationResource, command: string) {
if (command === 'status') void toggleStatus(row)
if (command === 'delete') void removeLocation(row)
}
watch(
() => access.can(editorPermission.value),
(allowed) => {
if (allowed || !editorOpen.value) return
drawerOpen.value = false
},
{ flush: 'sync' },
)
watch([page, pageSize], () => { void loadLocations() })
watch(selectedCustomerId, (customerId) => {
drawerOpen.value = false
loadedCustomerId.value = null
warehouses.value = []
filters.value = { ...filters.value, warehouseId: '' }
if (customerId) void loadWarehouses(customerId)
})
watch(selectedWarehouseId, () => {
drawerOpen.value = false
})
onMounted(() => {
void loadCustomers()
void loadLocations()
})
</script>
<template>
<div class="business-page location-page">
<QueryToolbar
v-model="filters"
ariaLabel="库位筛选"
:fields="queryFields"
@search="applyFilters"
>
<template #actions>
<el-button
v-if="access.can('platform:location:create')"
type="primary"
:disabled="!createReady"
@click="openCreate"
><Plus :size="16" />新增库位</el-button>
</template>
</QueryToolbar>
<el-alert v-if="customerError" type="warning" :closable="false" show-icon class="state-alert">
<template #title>所属企业列表加载失败</template>
<p>{{ customerError.message }}</p>
<ApiErrorMeta :details="customerError" />
<el-button size="small" @click="loadCustomers">重新加载所属企业</el-button>
</el-alert>
<el-alert v-if="warehouseError" type="error" :closable="false" show-icon class="state-alert">
<template #title>仓库列表加载失败</template>
<p>{{ warehouseError.message }}</p>
<ApiErrorMeta :details="warehouseError" />
<el-button size="small" @click="loadWarehouses()">重新加载仓库</el-button>
</el-alert>
<el-alert v-else-if="customerReady && warehouses.length === 0" type="info" :closable="false" show-icon title="当前所属企业暂无仓库">
请先在仓库管理中建立仓库,再维护库位。
</el-alert>
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
<template #title>库位列表加载失败</template>
<p>{{ loadError.message }}</p>
<ApiErrorMeta :details="loadError" />
<el-button size="small" @click="loadLocations()">重新查询</el-button>
</el-alert>
<section class="surface-panel table-panel">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100, 200]"
row-key="id"
aria-label="库位列表"
:empty-text="emptyDescription"
>
<template #actions="{ row }">
<div class="table-actions location-actions">
<el-button link type="primary" :disabled="loading" @click="loadDetail(row as PlatformLocationResource, 'view')">
<Eye :size="15" />详情
</el-button>
<el-button
v-if="canEdit(row as PlatformLocationResource)"
link
type="primary"
:disabled="loading"
@click="loadDetail(row as PlatformLocationResource, 'edit')"
><Pencil :size="15" />编辑</el-button>
<el-dropdown
v-if="canChangeStatus(row as PlatformLocationResource) || canDelete(row as PlatformLocationResource)"
trigger="click"
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
@command="handleMoreAction(row as PlatformLocationResource, String($event))"
>
<el-button link type="primary" aria-label="更多库位操作"><MoreHorizontal :size="16" />更多</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="canChangeStatus(row as PlatformLocationResource)" command="status">
<Power :size="15" />{{ (row as PlatformLocationResource).status === 'AVAILABLE' ? '停用' : '启用' }}
</el-dropdown-item>
<el-dropdown-item v-if="canDelete(row as PlatformLocationResource)" command="delete" divided>
<Trash2 :size="15" />删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<template #location="{ row }">
<div class="location-cell">
<span><MapPin :size="17" /></span>
<div>
<strong>{{ (row as PlatformLocationResource).locationCode }}</strong>
<small>{{ (row as PlatformLocationResource).locationName || '未命名库位' }}</small>
</div>
<el-tag v-if="(row as PlatformLocationResource).sharedOverflow" size="small" type="warning">系统位</el-tag>
</div>
</template>
<template #zone="{ row }">
<div class="stacked-value"><strong>{{ (row as PlatformLocationResource).zoneName }}</strong><small>{{ (row as PlatformLocationResource).zoneCode }}</small></div>
</template>
<template #warehouse="{ row }">
<div class="stacked-value">
<strong>{{ (row as PlatformLocationResource).warehouseName || selectedWarehouse?.warehouseName || '—' }}</strong>
<small>{{ (row as PlatformLocationResource).warehouseCode || selectedWarehouse?.warehouseCode || '—' }}</small>
</div>
</template>
<template #locationType="{ row }">{{ typeLabels[(row as PlatformLocationResource).locationType] }}</template>
<template #maxWeightKg="{ row }">{{ formatWeight((row as PlatformLocationResource).maxWeightKg) }}</template>
<template #status="{ row }">
<el-tag :type="statusMeta[(row as PlatformLocationResource).status].type" effect="light">
{{ statusMeta[(row as PlatformLocationResource).status].label }}
</el-tag>
</template>
<template #remark="{ row }"><span class="remark-cell">{{ (row as PlatformLocationResource).remark || '—' }}</span></template>
<template #empty>
<el-empty v-if="!loading" :description="emptyDescription">
<el-button
v-if="selectedWarehouseId && !filtered && access.can('platform:location:create')"
type="primary"
@click="openCreate"
><Plus :size="16" />新增第一个库位</el-button>
</el-empty>
</template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个库位</span></template>
</DataTable>
</section>
<el-drawer
v-model="drawerOpen"
:title="drawerTitle"
size="min(620px, 100vw)"
class="business-drawer location-drawer"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
>
<div v-loading="drawerLoading" class="drawer-content">
<el-alert v-if="drawerError" type="error" :closable="false" show-icon title="库位详情加载失败">
<p>{{ drawerError.message }}</p>
<ApiErrorMeta :details="drawerError" />
</el-alert>
<template v-else-if="drawerMode === 'view' && detail">
<section class="detail-identity">
<div class="detail-identity__icon"><MapPin :size="24" /></div>
<div>
<h3>{{ detail.location.locationCode }}</h3>
<p>{{ detail.location.locationName || '未命名库位' }}</p>
</div>
<el-tag :type="statusMeta[detail.location.status].type">{{ statusMeta[detail.location.status].label }}</el-tag>
</section>
<el-descriptions :column="2" border class="detail-descriptions">
<el-descriptions-item label="所属企业">{{ customerLabel(detail.location.tenantId) }}</el-descriptions-item>
<el-descriptions-item label="所属仓库">{{ detail.warehouseName }}({{ detail.warehouseCode }})</el-descriptions-item>
<el-descriptions-item label="库位编码">{{ detail.location.locationCode }}</el-descriptions-item>
<el-descriptions-item label="库位名称">{{ detail.location.locationName || '—' }}</el-descriptions-item>
<el-descriptions-item label="库区">{{ detail.location.zoneName }}({{ detail.location.zoneCode }})</el-descriptions-item>
<el-descriptions-item label="库位类型">{{ typeLabels[detail.location.locationType] }}</el-descriptions-item>
<el-descriptions-item label="最大承重">{{ formatWeight(detail.location.maxWeightKg) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusMeta[detail.location.status].label }}</el-descriptions-item>
<el-descriptions-item label="资源版本">v{{ detail.location.version }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(detail.location.updatedAt) }}</el-descriptions-item>
<el-descriptions-item label="系统共享位">{{ detail.location.sharedOverflow ? '是,仅系统自动分配' : '否' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ detail.location.remark || '—' }}</el-descriptions-item>
</el-descriptions>
<el-alert
v-if="detail.location.status === 'RESERVED' || detail.location.status === 'OCCUPIED'"
type="warning"
:closable="false"
show-icon
title="当前库位正在被业务使用,只允许查看。"
/>
<el-alert
v-if="detail.location.sharedOverflow"
type="info"
:closable="false"
show-icon
title="A0 是系统共享溢出位,不能编辑、停用或删除。"
/>
</template>
<el-form
v-else-if="drawerMode !== 'view'"
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="drawer-form"
:disabled="saving"
>
<div class="editor-scope">
<MapPin :size="18" />
<div><strong>{{ detail?.warehouseName || selectedWarehouse?.warehouseName }}({{ detail?.warehouseCode || selectedWarehouse?.warehouseCode }})</strong><span>所属企业:{{ customerLabel(form.customerId) }} · 保存后不可跨仓移动</span></div>
</div>
<section class="form-section">
<h3>库位标识</h3>
<p class="section-description">库位编码在当前企业和仓库内唯一;编辑后不能移动到其他仓库。</p>
<div class="form-grid two-cols">
<el-form-item label="库位编码" prop="locationCode" required>
<el-input v-model.trim="form.locationCode" maxlength="50" placeholder="例如 A1-01" />
</el-form-item>
<el-form-item label="库位名称">
<el-input v-model.trim="form.locationName" maxlength="100" placeholder="例如 A1 区 01 位" />
</el-form-item>
<el-form-item label="库区编码" prop="zoneCode" required>
<el-input v-model.trim="form.zoneCode" maxlength="50" placeholder="例如 A" />
</el-form-item>
<el-form-item label="库区名称" prop="zoneName" required>
<el-input v-model.trim="form.zoneName" maxlength="100" placeholder="例如 常规区" />
</el-form-item>
</div>
</section>
<section class="form-section">
<h3>资源属性</h3>
<div class="form-grid two-cols">
<el-form-item label="库位类型" prop="locationType" required>
<el-select v-model="form.locationType" placeholder="请选择库位类型">
<el-option label="货架位" value="SHELF" />
<el-option label="托盘位" value="PALLET" />
<el-option label="地堆位" value="FLOOR" />
<el-option label="流利架" value="FLOW_RACK" />
</el-select>
</el-form-item>
<el-form-item label="最大承重(kg)">
<el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" :step="10" controls-position="right" placeholder="选填" />
</el-form-item>
</div>
<el-form-item v-if="drawerMode === 'create'" label="初始状态" prop="status" required>
<el-radio-group v-model="form.status">
<el-radio-button value="AVAILABLE" :disabled="selectedWarehouse?.status !== 'ACTIVE'">可用</el-radio-button>
<el-radio-button value="DISABLED">停用</el-radio-button>
</el-radio-group>
<p v-if="selectedWarehouse?.status !== 'ACTIVE'" class="form-help">当前仓库已停用,新库位只能以停用状态创建。</p>
</el-form-item>
<div v-else class="readonly-status">
<span>当前状态</span>
<el-tag :type="statusMeta[form.status].type">{{ statusMeta[form.status].label }}</el-tag>
<small>状态只能通过列表中的启停操作变更。</small>
</div>
</section>
<section class="form-section">
<h3>备注</h3>
<el-form-item label="内部说明">
<el-input v-model.trim="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入库位位置、使用限制或其他说明" />
</el-form-item>
</section>
</el-form>
</div>
<template #footer>
<div class="drawer-footer">
<el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'view' ? '关闭' : '取消' }}</el-button>
<el-button v-if="drawerMode !== 'view'" type="primary" :loading="saving" @click="saveLocation">保存库位</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.state-alert p { margin: 6px 0 0; line-height: 1.55; }
.state-alert .el-button { margin-top: 10px; }
.location-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.location-actions :deep(.el-button) { margin-left: 0; }
.location-cell { display: flex; min-width: 0; align-items: center; gap: 10px; }
.location-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
.location-cell > div, .stacked-value { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.location-cell strong, .stacked-value strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.location-cell small, .stacked-value small { color: var(--cc-muted); font-size: 11px; }
.remark-cell { display: -webkit-box; overflow: hidden; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.drawer-content { min-height: 260px; }
.drawer-content > .el-alert + .el-alert, .detail-descriptions + .el-alert { margin-top: 14px; }
.detail-identity { display: grid; grid-template-columns: 48px minmax(0, 1fr) auto; align-items: center; gap: 12px; margin-bottom: 18px; }
.detail-identity__icon { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 10px; background: #eef3fa; color: var(--cc-primary); }
.detail-identity h3 { margin: 0; font-size: 18px; }
.detail-identity p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
.detail-descriptions :deep(.el-descriptions__label) { width: 118px; }
.section-description { margin: -3px 0 14px; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.drawer-form :deep(.el-input-number), .drawer-form :deep(.el-select) { width: 100%; }
.editor-scope { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; padding: 12px; background: #f7f9fc; color: var(--cc-primary); }
.editor-scope div { display: grid; min-width: 0; gap: 2px; }
.editor-scope strong, .editor-scope span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.editor-scope span { color: var(--cc-muted); font-size: 12px; }
.form-help { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.readonly-status { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 12px; background: #f7f9fc; }
.readonly-status > span { color: var(--cc-text); font-size: 13px; font-weight: 600; }
.readonly-status small { color: var(--cc-muted); font-size: 11px; }
@media (max-width: 720px) {
.detail-identity { grid-template-columns: 44px minmax(0, 1fr); }
.detail-identity > .el-tag { grid-column: 2; justify-self: start; }
.detail-descriptions :deep(.el-descriptions__body), .detail-descriptions :deep(table), .detail-descriptions :deep(tbody) { display: block; width: 100%; }
.detail-descriptions :deep(tr) { display: grid; grid-template-columns: 108px minmax(0, 1fr); }
.detail-descriptions :deep(.el-descriptions__cell) { display: block; width: auto; }
}
</style>
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
createPlatformPallet,
deletePlatformPallet,
getPlatformPallet,
listPlatformPallets,
updatePlatformPallet,
type SavePlatformPalletRequest,
} from './platformPallets'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
const payload: SavePlatformPalletRequest = {
warehouseId: '19001',
palletCode: ' TP-001 ',
palletType: '木托盘',
capacityCbm: 1.2,
remark: null,
}
describe('platform pallet API', () => {
beforeEach(() => {
vi.mocked(http.get).mockReset()
vi.mocked(http.post).mockReset()
vi.mocked(http.put).mockReset()
vi.mocked(http.delete).mockReset()
})
it('uses the real page contract and normalizes long pagination fields', async () => {
vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '41', pages: '3' },
})
await expect(listPlatformPallets({
tenantId: '9223372036854775806', page: 2, size: 20, warehouseId: '19001', palletCode: ' TP ', palletType: ' 木托盘 ',
})).resolves.toEqual({ records: [], page: 2, size: 20, total: 41, pages: 3 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/pallets', {
params: {
tenantId: '9223372036854775806', page: 2, size: 20, warehouseId: '19001', palletCode: 'TP', palletType: '木托盘',
},
})
})
it('omits the customer parameter for an all-customer page query', async () => {
vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 },
})
await listPlatformPallets({ page: 2, size: 20 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/pallets', {
params: { page: 2, size: 20 },
})
expect(vi.mocked(http.get).mock.calls[0]?.[1]?.params).not.toHaveProperty('tenantId')
})
it('uses tenant and warehouse scope for detail, update, and delete', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { id: '20001' } })
vi.mocked(http.post).mockResolvedValue({ code: '200', data: '20001' })
vi.mocked(http.put).mockResolvedValue({ code: '200', data: null })
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: null })
await expect(getPlatformPallet(10001, '19001', '20001')).resolves.toEqual({ id: '20001' })
await expect(createPlatformPallet(10001, payload)).resolves.toBe('20001')
await expect(updatePlatformPallet(10001, '19001', '20001', payload)).resolves.toBeNull()
await expect(deletePlatformPallet(10001, '19001', '20001')).resolves.toBeNull()
expect(http.get).toHaveBeenCalledWith('/platform/v1/pallets/20001', { params: { tenantId: 10001, warehouseId: '19001' } })
expect(http.post).toHaveBeenCalledWith('/platform/v1/pallets', payload, { params: { tenantId: 10001 } })
expect(http.put).toHaveBeenCalledWith('/platform/v1/pallets/20001', payload, { params: { tenantId: 10001, warehouseId: '19001' } })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/pallets/20001', { params: { tenantId: 10001, warehouseId: '19001' } })
})
})
@@ -0,0 +1,103 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface PlatformPalletResource {
id: number | string
tenantId: number | string
warehouseId: number | string
palletCode: string
palletType: string
capacityCbm: number | string
remark: string | null
createdAt: string | null
updatedAt: string | null
}
export interface PlatformPalletPage {
records: PlatformPalletResource[]
page: number
size: number
total: number
pages: number
}
export interface PlatformPalletQuery {
tenantId?: number | string
page: number
size: number
warehouseId?: number | string
palletCode?: string
palletType?: string
}
export interface SavePlatformPalletRequest {
warehouseId: number | string
palletCode: string
palletType: string
capacityCbm: number
remark: string | null
}
const basePath = '/platform/v1/pallets'
export async function listPlatformPallets(query: PlatformPalletQuery) {
const response = await http.get<ApiEnvelope<PlatformPalletPage>, ApiEnvelope<PlatformPalletPage>>(basePath, {
params: {
...(query.tenantId === undefined || query.tenantId === '' ? {} : { tenantId: query.tenantId }),
page: query.page,
size: query.size,
...(query.warehouseId === undefined || query.warehouseId === '' ? {} : { warehouseId: query.warehouseId }),
...(query.palletCode?.trim() ? { palletCode: query.palletCode.trim() } : {}),
...(query.palletType?.trim() ? { palletType: query.palletType.trim() } : {}),
},
})
const result = unwrapApiResponse(response)
return {
...result,
page: Number(result.page),
size: Number(result.size),
total: Number(result.total),
pages: Number(result.pages),
}
}
export async function getPlatformPallet(
tenantId: number | string,
warehouseId: number | string,
id: number | string,
) {
const response = await http.get<ApiEnvelope<PlatformPalletResource>, ApiEnvelope<PlatformPalletResource>>(
`${basePath}/${id}`,
{ params: { tenantId, warehouseId } },
)
return unwrapApiResponse(response)
}
export async function createPlatformPallet(tenantId: number | string, payload: SavePlatformPalletRequest) {
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, payload, {
params: { tenantId },
})
return unwrapApiResponse(response)
}
export async function updatePlatformPallet(
tenantId: number | string,
warehouseId: number | string,
id: number | string,
payload: SavePlatformPalletRequest,
) {
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, payload, {
params: { tenantId, warehouseId },
})
return unwrapApiResponse(response)
}
export async function deletePlatformPallet(
tenantId: number | string,
warehouseId: number | string,
id: number | string,
) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, {
params: { tenantId, warehouseId },
})
return unwrapApiResponse(response)
}
@@ -0,0 +1,317 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants as listPlatformCustomers, type PlatformTenantResource as PlatformCustomerResource } from '@/modules/iam'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import DataTable from '@/shared/components/DataTable.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import {
createPlatformPallet,
getPlatformPallet,
listPlatformPallets,
type PlatformPalletResource,
} from '../api/platformPallets'
import PalletsView from './PalletsView.vue'
vi.mock('@/modules/iam', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/modules/iam')>()
const listPlatformTenants = vi.fn<typeof actual.listPlatformTenants>()
return { ...actual, listPlatformTenants }
})
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
vi.mock('../api/platformPallets', () => ({
listPlatformPallets: vi.fn(),
getPlatformPallet: vi.fn(),
createPlatformPallet: vi.fn(),
updatePlatformPallet: vi.fn(),
deletePlatformPallet: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size },
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
}
}
const customer = (id: number | string, tenantName: string): PlatformCustomerResource => ({
id, tenantCode: String(id), tenantName, status: 'ACTIVE', legalName: tenantName,
legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null,
contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null,
salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null,
registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null,
internalRemark: null, createdAt: null, approvedAt: null,
})
const warehouse: PlatformWarehouseResource = {
id: '19001', tenantId: 10001, warehouseCode: 'CN-YW-01', warehouseName: '义乌收货仓',
warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null,
contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null,
}
const pallet: PlatformPalletResource = {
id: '20001', tenantId: 10001, warehouseId: '19001', palletCode: 'TP-001', palletType: '木托盘',
capacityCbm: '1.200000', remark: '标准川字托盘', createdAt: '2026-08-04T09:00:00', updatedAt: '2026-08-04T10:00:00',
}
async function mountView(permissions = [
'platform:pallet:list', 'platform:pallet:create', 'platform:pallet:edit', 'platform:pallet:delete',
]) {
vi.stubGlobal('localStorage', createMemoryStorage())
vi.stubGlobal('sessionStorage', createMemoryStorage())
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
return wrapper
}
async function setFilterContext(wrapper: VueWrapper, customerId = '10001', warehouseId = '') {
wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', {
customerId,
warehouseId: '',
palletCode: '',
palletType: '',
})
await flushPromises()
if (!warehouseId) return
wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', {
customerId,
warehouseId,
palletCode: '',
palletType: '',
})
await flushPromises()
}
async function loadRows(wrapper: VueWrapper, warehouseId = '') {
await setFilterContext(wrapper, '10001', warehouseId)
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
customerId: '10001',
warehouseId,
palletCode: '',
palletType: '',
})
await flushPromises()
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('PalletsView', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
document.body.innerHTML = ''
vi.mocked(listPlatformCustomers).mockReset().mockResolvedValue({
records: [customer(10001, '默认企业'), customer('9223372036854775806', '雪花企业')], page: 1, size: 200, total: 2, pages: 1,
})
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listPlatformPallets).mockReset().mockResolvedValue({ records: [pallet], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getPlatformPallet).mockReset().mockResolvedValue(pallet)
vi.mocked(createPlatformPallet).mockReset().mockResolvedValue('20002')
})
it('starts with all customers and uses compact semantic filter widths', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
const fields = toolbar.props('fields')
expect(toolbar.props('modelValue')).toMatchObject({ customerId: '', warehouseId: '' })
expect(fields.map((field) => [field.key, field.width])).toEqual([
['customerId', 260],
['warehouseId', 240],
['palletCode', 240],
['palletType', 180],
])
expect(wrapper.find('.pallet-filter-toolbar').exists()).toBe(true)
expect(fields.find((field) => field.key === 'customerId')?.placeholder).toBe('全部客户')
expect(listPlatformWarehouses).not.toHaveBeenCalled()
expect(listPlatformPallets).toHaveBeenCalledWith({
page: 1, size: 20,
})
})
it('loads warehouses after customer selection and omits an empty warehouse id when searching', async () => {
const wrapper = await mountView()
await loadRows(wrapper)
expect(listPlatformWarehouses).toHaveBeenCalledWith(10001, 1, 200)
expect(listPlatformPallets).toHaveBeenCalledWith({
page: 1, size: 20, tenantId: 10001,
})
expect(wrapper.text()).toContain('TP-001')
expect(wrapper.text()).toContain('木托盘')
expect(wrapper.text()).toContain('1.2 m³')
expect(wrapper.text()).toContain('义乌收货仓')
expect(wrapper.text()).toContain('托盘列表')
expect(wrapper.text()).not.toContain('启停托盘')
expect(wrapper.text()).not.toContain('全部状态')
expect(wrapper.text()).toContain('全部托盘类型')
expect(wrapper.find('input[aria-label="所属企业"]').exists()).toBe(true)
expect(wrapper.find('input[aria-label="所属仓库"]').exists()).toBe(true)
expect(wrapper.find('.platform-tenant-selector').exists()).toBe(false)
expect(wrapper.text()).not.toContain('租户')
expect(wrapper.find('.data-table').exists()).toBe(true)
expect(wrapper.find('.table-actions.pallet-row-actions').exists()).toBe(true)
expect(wrapper.findAll('thead th').map((item) => item.text().trim())[0]).toBe('操作')
})
it('queries all customers explicitly without adding a customer id', async () => {
vi.mocked(listPlatformPallets).mockResolvedValue({ records: [pallet], page: 1, size: 20, total: 41, pages: 3 })
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('search', { customerId: '', warehouseId: '', palletCode: '', palletType: '' })
await flushPromises()
expect(listPlatformPallets).toHaveBeenLastCalledWith({
page: 1, size: 20,
})
})
it('resets customer and dependent warehouse filters without sending an empty id', async () => {
const wrapper = await mountView()
await setFilterContext(wrapper, '10001', '19001')
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.reset()
await flushPromises()
expect(toolbar.props('modelValue')).toMatchObject({ customerId: '', warehouseId: '' })
expect(listPlatformPallets).toHaveBeenCalledTimes(2)
expect(listPlatformPallets).toHaveBeenLastCalledWith({
page: 1, size: 20,
})
expect(toolbar.props('fields').find((field) => field.key === 'customerId')?.placeholder).toBe('全部客户')
})
it('preserves a snowflake customer id when switching the enterprise filter', async () => {
const wrapper = await mountView()
const snowflakeId = '9223372036854775806'
wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', {
customerId: snowflakeId,
warehouseId: '',
palletCode: '',
palletType: '',
})
await flushPromises()
expect(listPlatformWarehouses).toHaveBeenLastCalledWith(snowflakeId, 1, 200)
})
it('applies pallet filters only after search', async () => {
const wrapper = await mountView()
await setFilterContext(wrapper, '10001', '19001')
await wrapper.find('input[placeholder="托盘编码(模糊查询)"]').setValue('TP-00')
expect(listPlatformPallets).toHaveBeenCalledTimes(1)
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
customerId: '10001', warehouseId: '19001', palletCode: 'TP-00', palletType: '木托盘',
})
await flushPromises()
expect(listPlatformPallets).toHaveBeenLastCalledWith({
page: 1, size: 20, tenantId: 10001, warehouseId: 19001, palletCode: 'TP-00', palletType: '木托盘',
})
})
it('creates a pallet in the selected warehouse with the real request fields', async () => {
const wrapper = await mountView()
await setFilterContext(wrapper, '10001', '19001')
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增托盘')
await createButton?.trigger('click')
await flushPromises()
await wrapper.find('input[placeholder="例如 TP-001"]').setValue('TP-002')
const palletTypeItem = wrapper.findAll('.el-form-item').find((item) => item.text().includes('托盘类型'))
palletTypeItem?.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '塑料托盘')
const capacity = wrapper.findComponent({ name: 'ElInputNumber' })
capacity.vm.$emit('update:modelValue', 1.5)
await wrapper.find('textarea[placeholder="可填写规格、材质或现场识别说明"]').setValue('蓝色标准托盘')
const saveButton = wrapper.findAll('button').find((button) => button.text().trim() === '保存托盘')
await saveButton?.trigger('click')
await flushPromises()
expect(createPlatformPallet).toHaveBeenCalledWith(10001, {
warehouseId: 19001, palletCode: 'TP-002', palletType: '塑料托盘', capacityCbm: 1.5, remark: '蓝色标准托盘',
})
})
it('hides all write actions without write permissions', async () => {
const wrapper = await mountView(['platform:pallet:list'])
expect(wrapper.find('.panel-heading button').exists()).toBe(false)
const actions = wrapper.find('.pallet-row-actions').text()
expect(actions).not.toContain('编辑')
expect(actions).not.toContain('删除')
expect(actions).toContain('查看')
})
it('shows a recoverable list failure instead of demo data', async () => {
vi.mocked(listPlatformPallets).mockRejectedValueOnce(new Error('Gateway unavailable'))
const wrapper = await mountView()
expect(wrapper.text()).toContain('托盘列表暂时无法加载')
expect(wrapper.text()).toContain('Gateway unavailable')
expect(wrapper.text()).toContain('重新加载')
expect(wrapper.text()).not.toContain('TP-001')
})
it('stops dependent requests when the enterprise filter cannot load', async () => {
vi.mocked(listPlatformCustomers).mockRejectedValueOnce(new Error('Gateway unavailable'))
const wrapper = await mountView()
expect(wrapper.text()).toContain('所属企业列表暂时无法加载')
expect(wrapper.text()).toContain('重新加载')
expect(listPlatformWarehouses).not.toHaveBeenCalled()
expect(listPlatformPallets).toHaveBeenCalledWith({
page: 1, size: 20,
})
})
it('shows only the pallet fields exposed by the backend contract', async () => {
const crossCustomerPallet = { ...pallet, tenantId: '9223372036854775806' }
vi.mocked(listPlatformPallets).mockResolvedValue({ records: [crossCustomerPallet], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getPlatformPallet).mockResolvedValue(crossCustomerPallet)
const wrapper = await mountView()
await loadRows(wrapper)
expect(wrapper.findComponent(DataTable).props('data')).toEqual([crossCustomerPallet])
const tableBody = wrapper.find('.el-table__body-wrapper')
const viewButton = tableBody.findAll('.pallet-row-actions button').find((button) => button.text().trim() === '查看')
await viewButton?.trigger('click')
await flushPromises()
expect(getPlatformPallet).toHaveBeenCalledWith('9223372036854775806', 19001, '20001')
expect(document.body.textContent).toContain('所属企业')
expect(document.body.textContent).toContain('雪花企业(9223372036854775806)')
expect(document.body.textContent).toContain('托盘编码')
expect(document.body.textContent).toContain('托盘类型')
expect(document.body.textContent).toContain('托盘容积')
expect(document.body.textContent).toContain('归属信息')
expect(document.body.textContent).toContain('托盘规格')
expect(document.body.textContent).toContain('记录信息')
expect(document.body.textContent).not.toContain('企业 ID')
expect(document.body.textContent).not.toContain('租户')
expect(document.body.textContent).not.toContain('托盘状态')
expect(document.body.textContent).not.toContain('最大承重')
})
})
@@ -0,0 +1,565 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Boxes, Eye, Pencil, Plus, Trash2, Warehouse as WarehouseIcon } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { listPlatformTenants as listPlatformCustomers, type PlatformTenantResource as PlatformCustomerResource } from '@/modules/iam'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import {
createPlatformPallet,
deletePlatformPallet,
getPlatformPallet,
listPlatformPallets,
updatePlatformPallet,
type PlatformPalletResource,
type SavePlatformPalletRequest,
} from '../api/platformPallets'
const access = useAccessPreviewStore()
const customers = ref<PlatformCustomerResource[]>([])
const customerLoading = ref(false)
const customerError = ref<ApiErrorDetails | null>(null)
const selectedCustomerId = ref('')
const selectedWarehouseId = ref('')
const warehouses = ref<PlatformWarehouseResource[]>([])
const warehouseLoading = ref(false)
const warehouseError = ref<ApiErrorDetails | null>(null)
const rows = ref<PlatformPalletResource[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const editorOpen = ref(false)
const detailOpen = ref(false)
const detail = ref<PlatformPalletResource | null>(null)
const detailLoading = ref(false)
const saving = ref(false)
const deletingIds = ref(new Set<string>())
const formRef = ref<FormInstance>()
let warehouseSequence = 0
let palletSequence = 0
type PalletFilters = QueryModel & { customerId: string; warehouseId: string; palletCode: string; palletType: string }
type AppliedPalletFilters = Pick<PalletFilters, 'palletCode' | 'palletType'>
const filters = ref<PalletFilters>({ customerId: '', warehouseId: '', palletCode: '', palletType: '' })
const appliedFilters = ref<AppliedPalletFilters>({ palletCode: '', palletType: '' })
const palletTypeOptions = ['木托盘', '塑料托盘', '金属托盘', '纸托盘', '免熏蒸托盘']
const queryFields = computed<QueryField[]>(() => [
{
key: 'customerId',
type: 'select',
width: 260,
placeholder: customerLoading.value ? '正在加载客户' : customerError.value ? '客户列表加载失败' : '全部客户',
options: customers.value.map((customer) => ({
label: `${customer.tenantName}(${customer.tenantCode || customer.id})`,
value: String(customer.id),
})),
props: { filterable: true, disabled: contextLocked.value || customerLoading.value || Boolean(customerError.value), 'aria-label': '所属企业' },
},
{
key: 'warehouseId',
type: 'select',
width: 240,
placeholder: warehouseLoading.value ? '正在加载所属仓库' : warehouseError.value ? '所属仓库加载失败' : '全部仓库',
options: warehouses.value.map((warehouse) => ({
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
value: String(warehouse.id),
disabled: warehouse.status !== 'ACTIVE',
})),
props: {
filterable: true,
disabled: contextLocked.value || !selectedCustomerId.value || warehouseLoading.value || Boolean(warehouseError.value),
'aria-label': '所属仓库',
},
},
{ key: 'palletCode', type: 'input', width: 240, placeholder: '托盘编码(模糊查询)', props: { maxlength: 50, 'aria-label': '托盘编码' } },
{
key: 'palletType',
type: 'select',
width: 180,
placeholder: '全部托盘类型',
options: palletTypeOptions.map((value) => ({ label: value, value })),
props: { filterable: true, allowCreate: true, defaultFirstOption: true, 'aria-label': '托盘类型' },
},
])
const emptyForm = () => ({ id: '', customerId: '', warehouseId: '', palletCode: '', palletType: '', capacityCbm: null as number | null, remark: '' })
const form = reactive(emptyForm())
const rules: FormRules<typeof form> = {
palletCode: [
{ required: true, message: '请输入托盘编码', trigger: 'blur' },
{ max: 50, message: '托盘编码不能超过 50 个字符', trigger: 'blur' },
],
palletType: [
{ required: true, message: '请输入托盘类型', trigger: 'blur' },
{ max: 32, message: '托盘类型不能超过 32 个字符', trigger: 'blur' },
],
capacityCbm: [{
validator: (_rule, value, callback) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) callback(new Error('请输入大于 0 的托盘容积'))
else callback()
},
trigger: 'change',
}],
remark: [{ max: 500, message: '备注不能超过 500 个字符', trigger: 'blur' }],
}
const currentWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === selectedWarehouseId.value) || null)
const editorWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === form.warehouseId) || null)
const currentCustomer = computed(() => customers.value.find((item) => String(item.id) === selectedCustomerId.value) || null)
const contextReady = computed(() => Boolean(selectedCustomerId.value && selectedWarehouseId.value && currentWarehouse.value))
const editorTitle = computed(() => form.id ? '编辑托盘' : '新增托盘')
const editorPermission = computed(() => form.id ? 'platform:pallet:edit' : 'platform:pallet:create')
const contextLocked = computed(() => editorOpen.value || detailOpen.value || saving.value || deletingIds.value.size > 0)
const filtered = computed(() => Object.values(appliedFilters.value).some((value) => String(value || '').trim()))
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'pallet-editor',
label: editorTitle,
isOpen: editorOpen,
model: form,
requiredPermission: editorPermission,
})
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', minWidth: 224, fixed: 'left', slotName: 'actions' },
{ key: 'pallet', label: '托盘编码', minWidth: 190, slotName: 'pallet' },
{ prop: 'palletType', label: '托盘类型', minWidth: 140 },
{ key: 'capacity', label: '托盘容积(m³)', minWidth: 160, slotName: 'capacity' },
{ key: 'warehouse', label: '所属仓库', minWidth: 210, slotName: 'warehouse' },
{ key: 'remark', label: '备注', minWidth: 220, slotName: 'remark' },
{ prop: 'updatedAt', label: '更新时间', minWidth: 160, formatter: (row: PlatformPalletResource) => formatDateTime(row.updatedAt) },
]
function apiId(value: string) {
const numeric = Number(value)
return Number.isSafeInteger(numeric) ? numeric : value
}
function formatDateTime(value?: string | null) {
return value?.replace('T', ' ').slice(0, 16) || '—'
}
function formatCapacity(value: number | string) {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return String(value)
return `${numeric.toLocaleString('zh-CN', { maximumFractionDigits: 6 })} m³`
}
function warehouseFor(warehouseId: number | string) {
return warehouses.value.find((item) => String(item.id) === String(warehouseId)) || null
}
function customerLabel(customerId: number | string) {
const customer = customers.value.find((item) => String(item.id) === String(customerId))
return customer ? `${customer.tenantName}(${customer.tenantCode || customer.id})` : '—'
}
function errorMessage(details: ApiErrorDetails) {
return details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message
}
async function loadCustomers() {
customerLoading.value = true
customerError.value = null
try {
const records: PlatformCustomerResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformCustomers({ page: currentPage, size: 200 })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
customers.value = records
} catch (error) {
customers.value = []
customerError.value = getApiErrorDetails(error, '所属企业列表加载失败,请稍后重试')
} finally {
customerLoading.value = false
}
}
async function loadWarehouses(customerId = selectedCustomerId.value) {
const sequence = ++warehouseSequence
selectedWarehouseId.value = ''
filters.value = { ...filters.value, warehouseId: '' }
warehouses.value = []
rows.value = []
total.value = 0
warehouseError.value = null
if (!customerId) return
warehouseLoading.value = true
try {
const result = await listPlatformWarehouses(apiId(customerId), 1, 200)
if (sequence !== warehouseSequence) return
warehouses.value = result.records
} catch (error) {
if (sequence !== warehouseSequence) return
warehouseError.value = getApiErrorDetails(error, '仓库列表加载失败,请稍后重试')
} finally {
if (sequence === warehouseSequence) warehouseLoading.value = false
}
}
async function loadPallets() {
const customerId = selectedCustomerId.value
const warehouseId = selectedWarehouseId.value
const sequence = ++palletSequence
rows.value = []
total.value = 0
loadError.value = null
loading.value = true
try {
const result = await listPlatformPallets({
page: page.value,
size: pageSize.value,
...(customerId ? { tenantId: apiId(customerId) } : {}),
...(warehouseId ? { warehouseId: apiId(warehouseId) } : {}),
...(appliedFilters.value.palletCode ? { palletCode: appliedFilters.value.palletCode } : {}),
...(appliedFilters.value.palletType ? { palletType: appliedFilters.value.palletType } : {}),
})
if (sequence !== palletSequence) return
rows.value = result.records
total.value = result.total
} catch (error) {
if (sequence !== palletSequence) return
loadError.value = getApiErrorDetails(error, '托盘列表加载失败,请稍后重试')
} finally {
if (sequence === palletSequence) loading.value = false
}
}
function applyFilters(model: QueryModel) {
selectedCustomerId.value = String(model.customerId || '')
selectedWarehouseId.value = String(model.warehouseId || '')
appliedFilters.value = { palletCode: String(model.palletCode || ''), palletType: String(model.palletType || '') }
if (page.value === 1) void loadPallets()
else page.value = 1
}
function resetFilters() {
selectedCustomerId.value = ''
selectedWarehouseId.value = ''
appliedFilters.value = { palletCode: '', palletType: '' }
if (page.value === 1) void loadPallets()
else page.value = 1
}
function openCreate() {
if (!contextReady.value) {
ElMessage.warning('请先选择所属企业和仓库')
return
}
Object.assign(form, emptyForm())
form.customerId = selectedCustomerId.value
form.warehouseId = selectedWarehouseId.value
editorOpen.value = true
captureBaseline()
void formRef.value?.clearValidate()
}
function assignForm(resource: PlatformPalletResource) {
Object.assign(form, {
id: String(resource.id),
customerId: String(resource.tenantId),
warehouseId: String(resource.warehouseId),
palletCode: resource.palletCode,
palletType: resource.palletType,
capacityCbm: Number(resource.capacityCbm),
remark: resource.remark || '',
})
editorOpen.value = true
captureBaseline()
void formRef.value?.clearValidate()
}
async function fetchDetail(row: PlatformPalletResource) {
return getPlatformPallet(apiId(String(row.tenantId)), apiId(String(row.warehouseId)), row.id)
}
async function openDetail(row: PlatformPalletResource) {
detailLoading.value = true
detailOpen.value = true
detail.value = null
try {
detail.value = await fetchDetail(row)
} catch (error) {
detailOpen.value = false
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘详情加载失败,请稍后重试')))
} finally {
detailLoading.value = false
}
}
async function openEdit(row: PlatformPalletResource) {
try {
assignForm(await fetchDetail(row))
} catch (error) {
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘详情加载失败,请稍后重试')))
}
}
async function savePallet() {
if ((!form.id && !contextReady.value) || !form.customerId || !form.warehouseId || !await formRef.value?.validate().catch(() => false) || form.capacityCbm === null) return
saving.value = true
const payload: SavePlatformPalletRequest = {
warehouseId: apiId(form.warehouseId),
palletCode: form.palletCode.trim(),
palletType: form.palletType.trim(),
capacityCbm: form.capacityCbm,
remark: form.remark.trim() || null,
}
try {
const customerId = apiId(form.customerId)
if (form.id) await updatePlatformPallet(customerId, apiId(form.warehouseId), form.id, payload)
else await createPlatformPallet(customerId, payload)
ElMessage.success(form.id ? '托盘已更新' : '托盘已新增')
editorOpen.value = false
await loadPallets()
} catch (error) {
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘保存失败,请稍后重试')))
} finally {
saving.value = false
}
}
async function removePallet(row: PlatformPalletResource) {
try {
await ElMessageBox.confirm(
`确定删除托盘“${row.palletCode}”吗?已预占或已占用的托盘无法删除。`,
'删除托盘',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'el-button--danger' },
)
} catch {
return
}
const id = String(row.id)
deletingIds.value = new Set([...deletingIds.value, id])
try {
await deletePlatformPallet(apiId(String(row.tenantId)), apiId(String(row.warehouseId)), row.id)
ElMessage.success('托盘已删除')
if (rows.value.length === 1 && page.value > 1) page.value -= 1
else await loadPallets()
} catch (error) {
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘删除失败,请稍后重试')))
} finally {
const next = new Set(deletingIds.value)
next.delete(id)
deletingIds.value = next
}
}
watch(() => filters.value.customerId, (customerId) => {
selectedCustomerId.value = String(customerId || '')
page.value = 1
void loadWarehouses(selectedCustomerId.value)
})
watch(() => filters.value.warehouseId, (warehouseId) => {
selectedWarehouseId.value = String(warehouseId || '')
page.value = 1
rows.value = []
total.value = 0
loadError.value = null
})
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextPage === previousPage && nextSize === previousSize) return
void loadPallets()
})
watch(() => access.can(editorPermission.value), (canWrite) => {
if (!canWrite && editorOpen.value) editorOpen.value = false
}, { flush: 'sync' })
onMounted(() => {
void loadCustomers()
void loadPallets()
})
</script>
<template>
<section class="pallet-page">
<LoadErrorState
v-if="customerError"
title="所属企业列表暂时无法加载"
:message="customerError.message"
:loading="customerLoading"
@retry="loadCustomers"
/>
<QueryToolbar
v-model="filters"
class="surface-panel pallet-filter-toolbar"
ariaLabel="托盘筛选"
:fields="queryFields"
:search-on-reset="false"
@search="applyFilters"
@reset="resetFilters"
/>
<LoadErrorState
v-if="warehouseError"
title="仓库列表暂时无法加载"
:message="warehouseError.message"
:loading="warehouseLoading"
@retry="loadWarehouses()"
/>
<LoadErrorState
v-if="loadError"
title="托盘列表暂时无法加载"
:message="loadError.message"
:loading="loading"
@retry="loadPallets"
/>
<section class="table-panel surface-panel" aria-label="托盘列表区域">
<div class="panel-heading">
<div><h2>托盘列表</h2><p>{{ currentCustomer?.tenantName || '全部客户' }} · {{ currentWarehouse?.warehouseName || '全部仓库' }} · 共 {{ total }} 个托盘</p></div>
<el-button v-if="access.can('platform:pallet:create')" type="primary" :disabled="!contextReady" @click="openCreate"><Plus :size="16" />新增托盘</el-button>
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
:loading="loading || warehouseLoading"
:total="total"
row-key="id"
:empty-text="filtered ? '暂无符合筛选条件的托盘' : selectedCustomerId ? '当前客户暂无托盘' : '暂无托盘数据'"
aria-label="托盘列表"
>
<template #actions="{ row }">
<div class="table-actions pallet-row-actions">
<el-button link type="primary" title="查看托盘" @click="openDetail(row as PlatformPalletResource)"><Eye :size="15" />查看</el-button>
<el-button v-if="access.can('platform:pallet:edit')" link type="primary" title="编辑托盘" @click="openEdit(row as PlatformPalletResource)"><Pencil :size="15" />编辑</el-button>
<el-button
v-if="access.can('platform:pallet:delete')"
link
type="danger"
title="删除托盘"
:loading="deletingIds.has(String((row as PlatformPalletResource).id))"
@click="removePallet(row as PlatformPalletResource)"
><Trash2 :size="15" />删除</el-button>
</div>
</template>
<template #pallet="{ row }">
<div class="pallet-cell"><span><Boxes :size="18" /></span><strong>{{ (row as PlatformPalletResource).palletCode }}</strong></div>
</template>
<template #capacity="{ row }"><strong>{{ formatCapacity((row as PlatformPalletResource).capacityCbm) }}</strong></template>
<template #warehouse="{ row }"><span>{{ warehouseFor((row as PlatformPalletResource).warehouseId)?.warehouseName || '—' }}</span></template>
<template #remark="{ row }"><span class="remark-text">{{ (row as PlatformPalletResource).remark || '—' }}</span></template>
</DataTable>
</section>
<el-drawer v-model="detailOpen" title="托盘详情" size="520px" class="business-drawer">
<div v-loading="detailLoading" class="detail-body">
<template v-if="detail">
<div class="detail-hero"><span><Boxes :size="22" /></span><div><h2>{{ detail.palletCode }}</h2><p>{{ detail.palletType }}</p></div></div>
<h3 class="detail-section-title">归属信息</h3>
<el-descriptions :column="1" border>
<el-descriptions-item label="所属企业">{{ customerLabel(detail.tenantId) }}</el-descriptions-item>
<el-descriptions-item label="所属仓库">{{ warehouseFor(detail.warehouseId)?.warehouseName || '—' }}({{ warehouseFor(detail.warehouseId)?.warehouseCode || '—' }})</el-descriptions-item>
</el-descriptions>
<h3 class="detail-section-title">托盘规格</h3>
<el-descriptions :column="1" border>
<el-descriptions-item label="托盘编码">{{ detail.palletCode }}</el-descriptions-item>
<el-descriptions-item label="托盘类型">{{ detail.palletType }}</el-descriptions-item>
<el-descriptions-item label="托盘容积">{{ formatCapacity(detail.capacityCbm) }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item>
</el-descriptions>
<h3 class="detail-section-title">记录信息</h3>
<el-descriptions :column="1" border>
<el-descriptions-item label="创建时间">{{ formatDateTime(detail.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(detail.updatedAt) }}</el-descriptions-item>
</el-descriptions>
</template>
</div>
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
</el-drawer>
<el-drawer v-model="editorOpen" :title="editorTitle" size="520px" class="business-drawer" :close-on-click-modal="false">
<div class="editor-context">
<WarehouseIcon :size="18" />
<div><strong>{{ editorWarehouse?.warehouseName || '当前仓库' }}({{ editorWarehouse?.warehouseCode || form.warehouseId }})</strong><span>所属企业 {{ customerLabel(form.customerId) }} · 托盘保存后不可跨仓移动</span></div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="editor-form">
<el-form-item label="托盘编码" prop="palletCode" required>
<el-input v-model="form.palletCode" maxlength="50" show-word-limit placeholder="例如 TP-001" />
</el-form-item>
<el-form-item label="托盘类型" prop="palletType" required>
<el-select
v-model="form.palletType"
filterable
allow-create
default-first-option
placeholder="请选择或输入托盘类型"
aria-label="编辑托盘类型"
>
<el-option v-for="type in palletTypeOptions" :key="type" :label="type" :value="type" />
</el-select>
</el-form-item>
<el-form-item label="托盘容积(m³)" prop="capacityCbm" required>
<el-input-number v-model="form.capacityCbm" :min="0.000001" :max="999999999999.999999" :precision="6" :step="0.1" controls-position="right" aria-label="标准容积" />
<p class="field-help">用于服务端按总体积计算所需托盘数量;请输入托盘实际可用容积。</p>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="可填写规格、材质或现场识别说明" />
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="savePallet">保存托盘</el-button></div>
</template>
</el-drawer>
</section>
</template>
<style scoped>
.pallet-page { display: grid; min-width: 0; gap: 16px; }
.surface-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.table-panel { min-width: 0; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 18px; border-bottom: 1px solid var(--cc-border); }
.panel-heading h2 { margin: 0; font-size: 16px; }
.panel-heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
.context-empty { padding: 56px 20px; color: var(--cc-muted); text-align: center; }
.pallet-row-actions { gap: 8px; white-space: nowrap; }
.pallet-row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.pallet-cell { display: flex; align-items: center; gap: 10px; }
.pallet-cell > span, .detail-hero > span { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--cc-primary); }
.pallet-cell > span { width: 34px; height: 34px; }
.remark-text { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-height: 1.55; }
.detail-body { min-height: 180px; }
.detail-hero { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.detail-hero > span { width: 46px; height: 46px; }
.detail-hero h2, .detail-hero p { margin: 0; }
.detail-hero h2 { font-size: 18px; }
.detail-hero p { margin-top: 4px; color: var(--cc-muted); font-size: 13px; }
.detail-section-title { margin: 20px 0 8px; color: var(--cc-text); font-size: 14px; }
.detail-section-title:first-of-type { margin-top: 0; }
.editor-context { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; padding: 12px; border: 1px solid var(--el-color-primary-light-7); border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--cc-primary); }
.editor-context div { display: grid; gap: 2px; }
.editor-context span { color: var(--cc-muted); font-size: 12px; }
.editor-form :deep(.el-input-number), .editor-form :deep(.el-select) { width: 100%; }
.field-help { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.drawer-footer { display: flex; justify-content: flex-end; gap: 8px; }
.drawer-footer :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) {
.panel-heading { align-items: stretch; flex-direction: column; }
.panel-heading > .el-button { width: 100%; min-height: 44px; }
.pallet-row-actions :deep(.el-button) { min-height: 36px; padding-inline: 5px; }
.drawer-footer { display: grid; grid-template-columns: 1fr 1fr; }
.drawer-footer :deep(.el-button) { min-height: 44px; margin-left: 0; }
}
@media (max-width: 768px) {
.pallet-filter-toolbar :deep(.query-toolbar__filters) { width: 100%; flex-direction: column; }
.pallet-filter-toolbar :deep(.query-toolbar__filters .el-input),
.pallet-filter-toolbar :deep(.query-toolbar__filters .el-select),
.pallet-filter-toolbar :deep(.query-toolbar__filters .el-date-editor) { width: 100% !important; }
}
</style>
+24
View File
@@ -0,0 +1,24 @@
import type { RouteRecordRaw } from 'vue-router'
export const warehouseResourceRoutes = [
{
path: 'master-data/locations',
name: 'locations',
component: () => import('./locations/pages/LocationsView.vue'),
meta: {
title: '库位管理',
description: '维护平台仓库库位和可用状态',
permission: 'platform:location:list',
},
},
{
path: 'master-data/pallets',
name: 'pallets',
component: () => import('./pallets/pages/PalletsView.vue'),
meta: {
title: '托盘管理',
description: '维护平台仓库托盘和可用容积',
permission: 'platform:pallet:list',
},
},
] satisfies RouteRecordRaw[]
+1
View File
@@ -0,0 +1 @@
export * from './api/platformWarehouses'
+11 -1
View File
@@ -16,6 +16,7 @@ export interface QueryOption {
export interface QueryField {
key: string
type: QueryFieldType
width?: string | number
placeholder?: string
options?: QueryOption[]
props?: Record<string, unknown>
@@ -53,6 +54,12 @@ function datePickerType(field: QueryField): 'date' | 'daterange' {
return field.type === 'daterange' ? 'daterange' : 'date'
}
function fieldStyle(field: QueryField) {
if (field.width === undefined) return undefined
const width = typeof field.width === 'number' ? `${field.width}px` : field.width
return { '--query-field-width': width }
}
function resetValue(field: QueryField): QueryValue {
return field.type === 'daterange' ? [] : ''
}
@@ -86,6 +93,7 @@ defineExpose({ search, reset })
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
v-bind="field.props"
:style="fieldStyle(field)"
@update:model-value="updateField(field.key, $event)"
>
<template #prefix><Search :size="16" /></template>
@@ -96,6 +104,7 @@ defineExpose({ search, reset })
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
v-bind="field.props"
:style="fieldStyle(field)"
@update:model-value="updateField(field.key, $event)"
>
<el-option v-for="option in field.options || []" :key="String(option.value)" :label="option.label" :value="option.value" :disabled="option.disabled" />
@@ -111,6 +120,7 @@ defineExpose({ search, reset })
start-placeholder="开始日期"
end-placeholder="结束日期"
v-bind="field.props"
:style="fieldStyle(field)"
@update:model-value="updateField(field.key, $event)"
/>
</template>
@@ -126,7 +136,7 @@ defineExpose({ search, reset })
<style scoped>
.query-toolbar { display: flex; align-items: flex-end; justify-content: flex-start; flex-wrap: wrap; gap: 10px; padding: 16px; border: 1px solid var(--cc-border); background: #fff; }
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 10px; }
.query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 260px; max-width: 100%; min-width: 0; }
.query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: var(--query-field-width, 260px); max-width: 100%; min-width: 0; }
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }