1
This commit is contained in:
Generated
+8
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/admin-web.iml" filepath="$PROJECT_DIR$/.idea/admin-web.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -12,6 +12,8 @@ services:
|
||||
ports:
|
||||
- "${HOST_PORT:-9001}:80"
|
||||
environment:
|
||||
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-}"
|
||||
# The test client is exposed on the same host through port 9002. Allow
|
||||
# deployments to override this, but never fall back to the server IP.
|
||||
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-http://test-admin.buggoglobal.com:9002}"
|
||||
API_UPSTREAM: "http://test-api-cn.buggoglobal.com"
|
||||
OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('domestic outbound list state contracts', () => {
|
||||
const truckingDetail = source('src/modules/trucking/pages/TruckAppointmentDetailView.vue')
|
||||
|
||||
expect(layout).toContain('<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">')
|
||||
expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute))"')
|
||||
expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + \':\' + JSON.stringify(renderedRoute.params)"')
|
||||
expect(oceanDetail).toContain("router.push('/ocean-bookings')")
|
||||
expect(truckingDetail).toContain("router.push({ name: 'trucking' })")
|
||||
})
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('AppLayout', () => {
|
||||
expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('消息管理')
|
||||
expect(wrapper.text()).toContain('模板管理')
|
||||
expect(wrapper.text()).not.toContain('我的消息')
|
||||
expect(wrapper.text()).toContain('我的消息')
|
||||
const notificationTrigger = wrapper.get('.notification-trigger')
|
||||
expect(notificationTrigger.find('.notification-count').exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -37,7 +37,8 @@ const menuExpanded = computed(() => (
|
||||
isMobileViewport.value ? mobileMenuOpen.value : !collapsed.value
|
||||
))
|
||||
const canOpenNotificationManagement = computed(() => (
|
||||
access.can('platform:notification:task:list')
|
||||
access.can('platform:notification:view')
|
||||
|| access.can('platform:notification:task:list')
|
||||
|| access.can('platform:notification:template:list')
|
||||
))
|
||||
|
||||
@@ -177,6 +178,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
||||
<Bell :size="18" /><span>消息中心</span>
|
||||
</button>
|
||||
<template #dropdown><el-dropdown-menu>
|
||||
<el-dropdown-item v-if="access.can('platform:notification:view')" @click="openNotificationPage('/notifications')"><Bell :size="15" />我的消息</el-dropdown-item>
|
||||
<el-dropdown-item v-if="access.can('platform:notification:task:list')" @click="openNotificationPage('/notifications/messages')"><Mail :size="15" />消息管理</el-dropdown-item>
|
||||
<el-dropdown-item v-if="access.can('platform:notification:template:list')" @click="openNotificationPage('/notifications/templates')"><FileText :size="15" />模板管理</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
@@ -193,7 +195,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="renderedRoute.meta.keepAlive !== false"
|
||||
:key="workspace.cacheKey(workspaceRouteId(renderedRoute))"
|
||||
:key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + ':' + JSON.stringify(renderedRoute.params)"
|
||||
/>
|
||||
</KeepAlive>
|
||||
<component :is="Component" v-if="renderedRoute.meta.keepAlive === false" />
|
||||
|
||||
@@ -110,6 +110,7 @@ describe('admin router access metadata', () => {
|
||||
|
||||
it('registers message-center routes with the frozen IAM permissions', () => {
|
||||
const expected = [
|
||||
['notification-inbox', '/notifications', 'platform:notification:view'],
|
||||
['notification-messages', '/notifications/messages', 'platform:notification:task:list'],
|
||||
['notification-templates', '/notifications/templates', 'platform:notification:template:list'],
|
||||
]
|
||||
|
||||
@@ -38,6 +38,8 @@ describe('admin nginx deployment config', () => {
|
||||
it('declares an explicit overseas gateway upstream in container deployments', () => {
|
||||
expect(testCompose).toContain('API_UPSTREAM: "http://test-api-cn.buggoglobal.com"')
|
||||
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"')
|
||||
expect(testCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-http://test-admin.buggoglobal.com:9002}"')
|
||||
expect(testCompose).not.toContain('CLIENT_WEB_URL: "http://8.163.')
|
||||
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"')
|
||||
expect(config).toContain('proxy_set_header Host $proxy_host;')
|
||||
})
|
||||
|
||||
@@ -193,6 +193,12 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(source).toContain('assignForm.packageCount = candidate.availablePackageCount')
|
||||
})
|
||||
|
||||
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||
expect(source).toContain('<div class="package-count-editor"><el-input-number')
|
||||
expect(source).toContain('.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }')
|
||||
})
|
||||
|
||||
it('rounds capacity percentages and hides column settings in embedded tables', async () => {
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||
...plan,
|
||||
@@ -496,6 +502,10 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(source).toContain('智能推荐')
|
||||
expect(source).toContain('推送预装')
|
||||
expect(source).toContain('推送预装的流程说明已由操作确认框承载')
|
||||
expect(source).toContain('后续还需补集装箱号、确认预装。确认继续?')
|
||||
expect(source).toContain('预装清单已生成,请继续完成集装箱号和预装确认')
|
||||
expect(source).not.toContain('关联并放舱真实订舱')
|
||||
expect(source).not.toContain('订舱放舱')
|
||||
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && selectedContainer?.containerNo")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -370,13 +370,13 @@ async function removeAll() {
|
||||
}
|
||||
async function pushPreload() {
|
||||
if (!plan.value) return
|
||||
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、关联并放舱真实订舱、确认预装。确认继续?', '推送预装清单', {
|
||||
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、确认预装。确认继续?', '推送预装清单', {
|
||||
type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消',
|
||||
}).catch(() => false)
|
||||
if (!confirmed || !plan.value) return
|
||||
await mutate(async () => {
|
||||
acceptPlan(await pushSmartLoadingPreload(plan.value!.id, plan.value!.version))
|
||||
ElMessage.success('预装清单已生成,请继续完成集装箱号、订舱放舱和预装确认')
|
||||
ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认')
|
||||
}, '推送预装清单失败')
|
||||
}
|
||||
|
||||
@@ -540,7 +540,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</div>
|
||||
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除整票</el-button><small v-if="row.detailCount > 1 && editable">多规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
|
||||
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
|
||||
<template #packageCount="{ row }"><div class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></div></template>
|
||||
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
|
||||
</DataTable>
|
||||
@@ -603,7 +603,8 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
.readonly-status-card strong { font-size: 16px; }
|
||||
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
|
||||
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
|
||||
.plan-pane :deep(.el-input-number) { width: 120px; }
|
||||
.package-count-editor { display: flex; min-width: 0; }
|
||||
.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
|
||||
.assign-dialog p { margin: 0 0 10px; }
|
||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
||||
@media (max-width: 1180px) {
|
||||
|
||||
@@ -99,6 +99,10 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(source).toContain("canPush && plan?.status === 'EDITING'")
|
||||
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && container?.containerNo")
|
||||
expect(source).toContain('推送后生成预装清单,不会直接开放 PDA')
|
||||
expect(source).toContain('后续还需补集装箱号、确认预装。确认继续?')
|
||||
expect(source).toContain('预装清单已生成,请继续完成集装箱号和预装确认')
|
||||
expect(source).not.toContain('关联并放舱真实订舱')
|
||||
expect(source).not.toContain('订舱放舱')
|
||||
expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)')
|
||||
expect(source).not.toContain("label: '目的仓'")
|
||||
expect(source).not.toContain('plan.planNo')
|
||||
@@ -107,4 +111,10 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(source).toContain('<span>集装箱号</span>')
|
||||
expect(source).not.toContain('当前柜号')
|
||||
})
|
||||
|
||||
it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
|
||||
expect(source).toContain('<div class="package-count-editor"><el-input-number')
|
||||
expect(source).toContain('.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -201,11 +201,11 @@ async function remove(source: object) {
|
||||
}
|
||||
async function pushPreload() {
|
||||
if (!plan.value) return
|
||||
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、关联并放舱真实订舱、确认预装。确认继续?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
|
||||
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、确认预装。确认继续?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
|
||||
if (!confirmed || !plan.value) return
|
||||
mutating.value = true
|
||||
mutationError.value = null
|
||||
try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号、订舱放舱和预装确认') }
|
||||
try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认') }
|
||||
catch (error) { await handleMutationError(error, '推送失败') }
|
||||
finally { mutating.value = false }
|
||||
}
|
||||
@@ -268,7 +268,7 @@ onMounted(load)
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable && row.canAdjust" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除整票</el-button><small v-if="row.detailCount > 1 && editable">含 {{ row.detailCount }} 个规格;改箱数请移除后重加</small><small v-if="row.pickedPackageCount > 0">已拣 {{ row.pickedPackageCount }} 箱</small></div></template>
|
||||
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
||||
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></template>
|
||||
<template #packageCount="{ row }"><div class="package-count-editor"><el-input-number v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :step="1" :disabled="!canAssign || !editable || !row.canAdjust" controls-position="right" /></div></template>
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
||||
</DataTable>
|
||||
<DataTable :data="candidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
|
||||
@@ -306,7 +306,8 @@ onMounted(load)
|
||||
.readonly-status-card p { margin: 0; color: var(--cc-muted); }
|
||||
.meter-title { justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 13px; }
|
||||
.row-actions { min-width: 150px; }
|
||||
.workbench :deep(.el-input-number) { width: 130px; }
|
||||
.package-count-editor { display: flex; min-width: 0; }
|
||||
.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
|
||||
.assign-dialog p { margin: 0 0 10px; }
|
||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
||||
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .workbench-identities { flex-direction: column; align-items: stretch; gap: 10px; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
|
||||
|
||||
@@ -9,7 +9,14 @@ const overseasPanelSource = readFileSync(resolve(process.cwd(), 'src/modules/cus
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), put: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
unwrapApiResponse: <T>(response: { data: T }) => {
|
||||
const value = response.data as T & { code?: string; message?: string; data?: unknown }
|
||||
if (value && typeof value === 'object' && 'code' in value) {
|
||||
if (value.code !== '200') throw new Error(value.message || 'request failed')
|
||||
return value.data as T
|
||||
}
|
||||
return value
|
||||
},
|
||||
}))
|
||||
|
||||
const config = { id: 12, customerId: 9007199254740993n.toString(), customerName: '客户', currency: 'USD', tiers: [], version: 0, createdAt: null, updatedAt: null }
|
||||
@@ -46,6 +53,19 @@ describe('customer overseas storage fee api', () => {
|
||||
expect(result.version).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects a business error returned by the update endpoint', async () => {
|
||||
vi.mocked(http.put).mockResolvedValue({ data: { code: 'A0400', message: '璇锋眰鏃犳晥', data: null } })
|
||||
|
||||
await expect(updateCustomerOverseasStorageFees(config.customerId, {
|
||||
customerId: config.customerId,
|
||||
customerName: '瀹㈡埛',
|
||||
currency: 'USD',
|
||||
version: 0,
|
||||
tiers: [{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 1, endDay: null, unitPrice: 0 }],
|
||||
})).rejects.toThrow('璇锋眰鏃犳晥')
|
||||
expect(http.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes an omitted end day as an open-ended tier', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { ...config, tiers: [{ id: 1, startDay: 91, unitPrice: 2, sortNo: 5, feeType: 'STORAGE', billingUnit: 'CBM' }] } })
|
||||
const result = await getCustomerOverseasStorageFees(config.customerId)
|
||||
|
||||
@@ -55,7 +55,8 @@ export async function getCustomerOverseasStorageFees(tenantId: string) {
|
||||
}
|
||||
|
||||
export async function updateCustomerOverseasStorageFees(tenantId: string, request: OverseasStorageFeeUpdateRequest) {
|
||||
await http.put<never, ApiEnvelope<null>>(path(tenantId), request)
|
||||
const response = await http.put<never, ApiEnvelope<null>>(path(tenantId), request)
|
||||
unwrapApiResponse(response)
|
||||
return getCustomerOverseasStorageFees(tenantId)
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,28 @@ describe('finance api', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('includes a supplier filter only for payable fee-pool requests', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [] })
|
||||
|
||||
await listFinanceFeePool('10001', 'PAYABLE', '301')
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/platform/finance/v1/fee-pool', {
|
||||
params: { customerId: '10001', direction: 'PAYABLE', supplierId: '301' },
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves settlement mode from fee-pool candidates for client-side eligibility checks', async () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{
|
||||
poolItemId: 'PAYABLE:42', customerId: 10001, direction: 'PAYABLE', sourceType: 'INBOUND_FEE', sourceId: 42,
|
||||
sourceVersion: 1, feeItemName: '进仓费', amount: 33, currency: 'CNY', settlementMode: 'DIRECT_PAID',
|
||||
status: 'AVAILABLE', createdAt: '2026-08-26T00:00:00',
|
||||
}] })
|
||||
|
||||
const [candidate] = await listFinanceFeePool('10001', 'PAYABLE', '301')
|
||||
|
||||
expect(candidate.settlementMode).toBe('DIRECT_PAID')
|
||||
})
|
||||
|
||||
it('uses the formal G1 account list path and fixed page DTO', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], page: { page: 2, pageSize: 20, total: 21 } })
|
||||
|
||||
|
||||
@@ -111,12 +111,14 @@ export async function getFinanceCustomerAccountSummary(customerId: string): Prom
|
||||
}
|
||||
}
|
||||
|
||||
export async function listFinanceFeePool(customerId?: string, direction?: 'RECEIVABLE' | 'PAYABLE'): Promise<FinanceFeePoolItem[]> {
|
||||
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/fee-pool`, { params: { customerId: customerId?.trim() || undefined, direction } })
|
||||
export async function listFinanceFeePool(customerId?: string, direction?: 'RECEIVABLE' | 'PAYABLE', supplierId?: string): Promise<FinanceFeePoolItem[]> {
|
||||
const params: Record<string, unknown> = { customerId: customerId?.trim() || undefined, direction }
|
||||
if (supplierId?.trim()) params.supplierId = supplierId.trim()
|
||||
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/fee-pool`, { params })
|
||||
return unwrapApiResponse(response).map((item) => {
|
||||
const poolItemId = stringValue(item.poolItemId)
|
||||
const feeSourceId = stringValue(item.sourceType) === 'MANUAL_COST' ? null : (poolItemId.match(/^[A-Z_]+:(\d+)$/)?.[1] ?? null)
|
||||
return { poolItemId, feeSourceId, customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) }
|
||||
return { poolItemId, feeSourceId, customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', settlementMode: item.settlementMode == null ? null : stringValue(item.settlementMode), status: stringValue(item.status), createdAt: stringValue(item.createdAt) }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,13 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
expect(adjustFinanceFeeSource).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the customer dropdown open while its option list is scrolled', async () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
|
||||
expect(source).not.toContain("document.addEventListener('scroll', closeCustomerSelectOnScroll, true)")
|
||||
expect(source).not.toContain('input?.blur()')
|
||||
expect(source).toContain("class: 'finance-customer-select'")
|
||||
})
|
||||
|
||||
it('uses sourceSystem to suppress Firstleg editing when the source type is not in the legacy fallback list', async () => {
|
||||
const firstlegOtherType = { ...feeSummary, sourceType: 'ORDER_FEE', reviewStatus: 'DRAFT' as const }
|
||||
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [firstlegOtherType], total: 1, pages: 1 })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Ban, Download, Eye, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -54,7 +54,6 @@ const router = useRouter()
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const customersError = ref<ApiErrorDetails | null>(null)
|
||||
const customerSelectOpen = ref(false)
|
||||
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', reviewStatus: '', statementGenerated: '', businessRange: [], keyword: '' })
|
||||
const allRows = ref<ArapRow[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -94,15 +93,6 @@ function isFirstlegSource(source: { sourceSystem?: unknown; sourceType?: unknown
|
||||
}
|
||||
const stateError = computed(() => customersError.value || loadError.value)
|
||||
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||||
function onCustomerSelectVisible(visible: boolean) {
|
||||
customerSelectOpen.value = visible
|
||||
}
|
||||
function closeCustomerSelectOnScroll() {
|
||||
if (!customerSelectOpen.value || typeof document === 'undefined') return
|
||||
const input = document.querySelector('.finance-customer-select input') as HTMLInputElement | null
|
||||
input?.blur()
|
||||
customerSelectOpen.value = false
|
||||
}
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'customerId',
|
||||
@@ -115,7 +105,6 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
class: 'finance-customer-select',
|
||||
popperClass: 'finance-customer-arap-popper',
|
||||
teleported: false,
|
||||
onVisibleChange: onCustomerSelectVisible,
|
||||
filterable: true,
|
||||
clearable: true,
|
||||
loading: customersLoading.value,
|
||||
@@ -343,15 +332,11 @@ async function restoreFeeSourceDetail(feeSourceId: string) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('scroll', closeCustomerSelectOnScroll, true)
|
||||
const feeSourceId = String(route.params.feeSourceId || '')
|
||||
if (route.name === 'finance-customer-arap-detail' && feeSourceId) { if (canListCustomers.value) await loadCustomers(); await restoreFeeSourceDetail(feeSourceId); return }
|
||||
if (canListCustomers.value) await loadCustomers()
|
||||
await load()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('scroll', closeCustomerSelectOnScroll, true)
|
||||
})
|
||||
watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSourceId], previous) => {
|
||||
if (!previous) return
|
||||
if (name === 'finance-customer-arap-detail' && feeSourceId) void restoreFeeSourceDetail(String(feeSourceId))
|
||||
|
||||
@@ -222,9 +222,38 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(vm.filteredRows).toHaveLength(1)
|
||||
vm.filters.containerNo = 'CONT-99'
|
||||
await flushPromises()
|
||||
expect(vm.filteredRows).toHaveLength(1)
|
||||
await vm.load({
|
||||
status: 'ISSUED', billNo: '501', customerId: '', containerNo: 'CONT-99',
|
||||
periodStartRange: ['2026-08-01', '2026-08-02'], periodEndRange: ['2026-08-30', '2026-09-01'],
|
||||
settled: 'false', lastPaymentRange: ['2026-08-20', '2026-08-20'],
|
||||
})
|
||||
await flushPromises()
|
||||
expect(vm.filteredRows).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps statement status filter options aligned with the list status column', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
fields: Array<{ key: string; label: string; placeholder?: string; options?: Array<{ label: string; value: string }> }>
|
||||
columns: Array<{ key: string; label: string }>
|
||||
}
|
||||
const statusField = vm.fields.find((field) => field.key === 'status')
|
||||
const statusColumn = vm.columns.find((column) => column.key === 'status')
|
||||
expect(statusField).toMatchObject({ label: '对账单状态', placeholder: '请选择对账单状态' })
|
||||
expect(statusField?.options).toEqual([
|
||||
{ label: '草稿', value: 'DRAFT' },
|
||||
{ label: '待审核', value: 'PENDING_REVIEW' },
|
||||
{ label: '已审核', value: 'APPROVED' },
|
||||
{ label: '已驳回', value: 'REJECTED' },
|
||||
{ label: '已出账', value: 'ISSUED' },
|
||||
{ label: '部分结清', value: 'PARTIALLY_PAID' },
|
||||
{ label: '已结清', value: 'PAID' },
|
||||
{ label: '已作废', value: 'VOIDED' },
|
||||
])
|
||||
expect(statusColumn).toEqual({ key: 'status', label: '对账单状态', width: 120, slotName: 'status' })
|
||||
})
|
||||
|
||||
it('creates a formal statement export task with only non-empty filters', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const vm = wrapper.vm as unknown as { filters: Record<string, unknown>; exportStatements(): Promise<void> }
|
||||
|
||||
@@ -18,7 +18,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
|
||||
import { financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
|
||||
import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
|
||||
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
|
||||
|
||||
interface AdjustmentRow {
|
||||
@@ -47,6 +47,7 @@ const filters = ref<QueryModel>({
|
||||
status: '', billNo: '', customerId: '', containerNo: '', periodStartRange: [], periodEndRange: [],
|
||||
settled: '', lastPaymentRange: [], createdRange: [],
|
||||
})
|
||||
const appliedFilters = ref<QueryModel>({ ...filters.value })
|
||||
const rows = ref<StatementRow[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
@@ -99,10 +100,7 @@ const selectedCreateAmount = computed(() => selectedCreateSources.value.reduce((
|
||||
const detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '')
|
||||
const isDetailRoute = computed(() => Boolean(detailRouteId.value))
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '审核状态', width: 180, placeholder: '请选择审核状态', options: [
|
||||
{ label: '草稿', value: 'DRAFT' }, { label: '待审核', value: 'PENDING_REVIEW' }, { label: '审核通过', value: 'APPROVED' },
|
||||
{ label: '审核拒绝', value: 'REJECTED' }, { label: '已出账', value: 'ISSUED' }, { label: '已作废', value: 'VOIDED' },
|
||||
] },
|
||||
{ key: 'status', type: 'select', label: '对账单状态', width: 180, placeholder: '请选择对账单状态', options: financeBillStatusOptions },
|
||||
{ key: 'billNo', type: 'input', label: '对账单号', width: 210, placeholder: '请输入对账单号' },
|
||||
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '请选择客户', props: { filterable: true, clearable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
{ key: 'containerNo', type: 'input', label: '柜次号', width: 190, placeholder: canViewFeeSource.value ? '请输入柜次号' : '缺少费用来源查看权限', props: { disabled: !canViewFeeSource.value } },
|
||||
@@ -114,7 +112,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'status', label: '审核状态', width: 120, slotName: 'status' },
|
||||
{ key: 'status', label: '对账单状态', width: 120, slotName: 'status' },
|
||||
{ key: 'customerConfirmationStatus', label: '客户确认', width: 130, slotName: 'customerConfirmationStatus' },
|
||||
{ key: 'billNo', label: '对账单号', minWidth: 180 },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
|
||||
@@ -208,9 +206,9 @@ function detailToRow(row: FinanceStatementReadModel, current?: FinanceReconcilia
|
||||
}
|
||||
}
|
||||
|
||||
function stringFilter(key: string) { return String(filters.value[key] || '').trim().toLowerCase() }
|
||||
function stringFilter(key: string) { return String(appliedFilters.value[key] || '').trim().toLowerCase() }
|
||||
function inDateRange(value: string, key: string) {
|
||||
const range = filters.value[key]
|
||||
const range = appliedFilters.value[key]
|
||||
if (!Array.isArray(range) || range.length < 2 || !value) return !Array.isArray(range) || range.length < 2
|
||||
const date = value.slice(0, 10)
|
||||
return date >= String(range[0]) && date <= String(range[1])
|
||||
@@ -251,6 +249,7 @@ async function load(model?: QueryModel) {
|
||||
const hydrateDetails = model !== undefined
|
||||
if (model) {
|
||||
filters.value = { ...model }
|
||||
appliedFilters.value = { ...model }
|
||||
page.value = 1
|
||||
}
|
||||
rows.value = []
|
||||
@@ -260,7 +259,7 @@ async function load(model?: QueryModel) {
|
||||
error.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
const statements = await listFinanceStatements(String(filters.value.customerId || '').trim() || undefined)
|
||||
const statements = await listFinanceStatements(String(appliedFilters.value.customerId || '').trim() || undefined)
|
||||
const detailErrors: ApiErrorDetails[] = []
|
||||
const hydrated = await Promise.all(statements.map(async (row) => {
|
||||
if (!hydrateDetails) return detailToRow(row)
|
||||
@@ -510,7 +509,7 @@ async function performAction(row: FinanceStatementReadModel, actionName: Reconci
|
||||
try {
|
||||
await actionReconciliationStatement(row.id, { action: actionName, expectedVersion: row.version, ...(reason ? { reason } : {}) })
|
||||
ElMessage.success('对账单状态已更新')
|
||||
await load(filters.value)
|
||||
await load(appliedFilters.value)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单操作失败') }
|
||||
finally { actionBusy.value = false }
|
||||
}
|
||||
@@ -540,7 +539,7 @@ async function submitAdjustment() {
|
||||
})
|
||||
adjustOpen.value = false
|
||||
ElMessage.success('对账单调整已保存')
|
||||
await load(filters.value)
|
||||
await load(appliedFilters.value)
|
||||
await openDetail(statement)
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单调整失败') }
|
||||
finally { adjustBusy.value = false }
|
||||
@@ -648,7 +647,7 @@ watch(detailRouteId, (statementId) => {
|
||||
</el-tabs>
|
||||
</DetailPageLayout>
|
||||
<ListPageLayout v-else aria-label="客户对账单">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" @search="load" /></template>
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></template>
|
||||
<template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template>
|
||||
<template v-if="!customerError && !error">
|
||||
<section class="surface-panel table-panel">
|
||||
|
||||
@@ -61,4 +61,8 @@ describe('formal settlement field contract', () => {
|
||||
expect(pageSource).not.toContain('prop="sourceVersion" label="来源版本"')
|
||||
expect(pageSource).not.toContain('{{ detail.version }}')
|
||||
})
|
||||
|
||||
it('does not offer directly paid costs as payable settlement sources', () => {
|
||||
expect(pageSource).toContain("item.settlementMode?.toUpperCase() !== 'DIRECT_PAID'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,6 +49,7 @@ const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const filters = ref<QueryModel>({ customerId: '', supplierName: '', settlementNo: '', status: '' })
|
||||
const appliedFilters = ref<QueryModel>({ ...filters.value })
|
||||
const rows = ref<SettlementRow[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
@@ -113,9 +114,9 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'settlementNo', type: 'input', label: '结算单号', width: 220, placeholder: '请输入结算单号' },
|
||||
])
|
||||
const filteredRows = computed(() => {
|
||||
const status = String(filters.value.status || '')
|
||||
const settlementNo = String(filters.value.settlementNo || '').trim().toLowerCase()
|
||||
const supplierName = String(filters.value.supplierName || '')
|
||||
const status = String(appliedFilters.value.status || '')
|
||||
const settlementNo = String(appliedFilters.value.settlementNo || '').trim().toLowerCase()
|
||||
const supplierName = String(appliedFilters.value.supplierName || '')
|
||||
return rows.value.filter((item) => (!status || item.status === status)
|
||||
&& (!settlementNo || item.settlementNo.toLowerCase().includes(settlementNo))
|
||||
&& (!supplierName || ('supplierName' in item && item.supplierName === supplierName)))
|
||||
@@ -233,13 +234,14 @@ async function loadCustomers() {
|
||||
async function load(model?: QueryModel) {
|
||||
if (model) {
|
||||
filters.value = { ...model }
|
||||
appliedFilters.value = { ...model }
|
||||
page.value = 1
|
||||
}
|
||||
rows.value = []
|
||||
error.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
const customerId = String(filters.value.customerId || '').trim() || undefined
|
||||
const customerId = String(appliedFilters.value.customerId || '').trim() || undefined
|
||||
rows.value = props.kind === 'receivable'
|
||||
? await listFinanceReceivableSettlements(customerId)
|
||||
: await listFinancePayableSettlements(customerId)
|
||||
@@ -304,8 +306,13 @@ async function refreshCreateDependencies(customerId?: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const fees = await listFinanceFeePool(selectedCustomerId, 'PAYABLE')
|
||||
feeOptions.value = fees.filter((item) => item.feeSourceId && item.status === 'AVAILABLE')
|
||||
const selectedSupplierId = String(createForm.value.supplierId || '').trim() || undefined
|
||||
if (!selectedSupplierId) {
|
||||
createForm.value.feeSourceIds = []
|
||||
return
|
||||
}
|
||||
const fees = await listFinanceFeePool(selectedCustomerId, 'PAYABLE', selectedSupplierId)
|
||||
feeOptions.value = fees.filter((item) => item.feeSourceId && item.status === 'AVAILABLE' && item.settlementMode?.toUpperCase() !== 'DIRECT_PAID')
|
||||
if (createForm.value.supplierId && !supplierOptions.value.find((supplier) => String(supplier.id) === String(createForm.value.supplierId))) {
|
||||
createForm.value.supplierId = ''
|
||||
}
|
||||
@@ -321,6 +328,12 @@ async function refreshCreateDependencies(customerId?: string) {
|
||||
watch(() => createForm.value.customerId, (customerId) => {
|
||||
if (createOpen.value) void refreshCreateDependencies(customerId)
|
||||
})
|
||||
watch(() => createForm.value.supplierId, () => {
|
||||
if (!createOpen.value) return
|
||||
createForm.value.feeSourceIds = []
|
||||
feeOptions.value = []
|
||||
if (createForm.value.customerId) void refreshCreateDependencies(createForm.value.customerId)
|
||||
})
|
||||
|
||||
async function submitCreate() {
|
||||
const customerId = String(createForm.value.customerId || '')
|
||||
@@ -580,7 +593,7 @@ onMounted(() => {
|
||||
</DetailPageLayout>
|
||||
<ListPageLayout v-else :aria-label="title">
|
||||
<template #toolbar>
|
||||
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" @search="load" />
|
||||
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" :search-on-clear="false" @search="load" />
|
||||
</template>
|
||||
<template v-if="customerError || error" #state>
|
||||
<LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState>
|
||||
|
||||
@@ -125,6 +125,25 @@ describe('Finance G3/G4 views', () => {
|
||||
expect(createFinanceSettlementPayment).toHaveBeenCalledWith('701', expect.objectContaining({ amount: 100, voucherFileIds: ['9001'], expectedVersion: 4 }))
|
||||
})
|
||||
|
||||
it('loads payable sources only for the selected supplier and clears them when the supplier changes', async () => {
|
||||
const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list', 'platform:finance:settlement-ap:create'], { kind: 'payable' })
|
||||
const vm = wrapper.vm as unknown as { openCreate(): Promise<void>; createForm: { customerId: string; supplierId: string; feeSourceIds: string[] } }
|
||||
|
||||
await vm.openCreate()
|
||||
vm.createForm.customerId = '10001'
|
||||
await flushPromises()
|
||||
expect(listFinanceFeePool).not.toHaveBeenCalled()
|
||||
|
||||
vm.createForm.supplierId = '301'
|
||||
await flushPromises()
|
||||
expect(listFinanceFeePool).toHaveBeenCalledWith('10001', 'PAYABLE', '301')
|
||||
|
||||
vm.createForm.feeSourceIds = ['81']
|
||||
vm.createForm.supplierId = ''
|
||||
await flushPromises()
|
||||
expect(vm.createForm.feeSourceIds).toEqual([])
|
||||
})
|
||||
|
||||
it('renders request and trace ids when G3 detail loading fails', async () => {
|
||||
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new ApiResponseError('对账单不可用', 'FIN_404', 404, undefined, 'req-g3-error', 'trace-g3-error'))
|
||||
const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list'])
|
||||
@@ -149,4 +168,19 @@ describe('Finance G3/G4 views', () => {
|
||||
expect(listFinanceReceivableSettlements).toHaveBeenLastCalledWith('10001')
|
||||
expect(listFinancePayableSettlements).toHaveBeenLastCalledWith('10001')
|
||||
})
|
||||
|
||||
it('applies settlement list text filters only after an explicit query', async () => {
|
||||
const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list'], { kind: 'payable' })
|
||||
const vm = wrapper.vm as unknown as {
|
||||
filters: Record<string, unknown>
|
||||
filteredRows: FinancePayableSettlement[]
|
||||
load(model: Record<string, unknown>): Promise<void>
|
||||
}
|
||||
vm.filters.settlementNo = 'NOT-FOUND'
|
||||
await flushPromises()
|
||||
expect(vm.filteredRows).toHaveLength(1)
|
||||
await vm.load({ customerId: '', supplierName: '', settlementNo: 'NOT-FOUND', status: '' })
|
||||
await flushPromises()
|
||||
expect(vm.filteredRows).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,11 +75,13 @@ describe('finance presentation governance', () => {
|
||||
expect(arap).toContain("placeholder: '请选择客户'")
|
||||
expect(arap).not.toContain("placeholder: '全部客户'")
|
||||
expect(statements).toContain("placeholder: '请选择客户'")
|
||||
expect(statements).toContain("placeholder: '请选择审核状态'")
|
||||
expect(statements).toContain("placeholder: '请选择对账单状态'")
|
||||
expect(statements).toContain(':search-on-clear="false"')
|
||||
expect(statements).toContain("placeholder: '请选择结清状态'")
|
||||
expect(statements).toContain("startPlaceholder: '请选择开始日期'")
|
||||
expect(statements).toContain("endPlaceholder: '请选择结束日期'")
|
||||
expect(statements).not.toContain("placeholder: '全部客户'")
|
||||
expect(settlement).toContain(':search-on-clear="false"')
|
||||
})
|
||||
|
||||
it('uses active-customer name selectors across all formal finance pages', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { financeBusinessTypeLabel, financeDirectionLabel, financeDocumentTypeLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from './status'
|
||||
import { financeArapStatusOptions, financeBillStatusOptions, financeBusinessTypeLabel, financeDirectionLabel, financeDocumentTypeLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from './status'
|
||||
|
||||
describe('finance status presentation', () => {
|
||||
it.each([
|
||||
@@ -47,4 +47,26 @@ describe('finance status presentation', () => {
|
||||
expect(financeDocumentTypeLabel('NEW_DOCUMENT')).toBe('未知单据类型')
|
||||
expect(financeOperationActionLabel('NEW_ACTION')).toBe('未知操作')
|
||||
})
|
||||
|
||||
it('uses review-status values for the customer AR/AP review filter', () => {
|
||||
expect(financeArapStatusOptions).toEqual([
|
||||
{ label: '草稿', value: 'DRAFT' },
|
||||
{ label: '待审核', value: 'PENDING' },
|
||||
{ label: '已审核', value: 'APPROVED' },
|
||||
{ label: '已驳回', value: 'REJECTED' },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the complete reconciliation statement lifecycle for statement filters', () => {
|
||||
expect(financeBillStatusOptions).toEqual([
|
||||
{ label: '草稿', value: 'DRAFT' },
|
||||
{ label: '待审核', value: 'PENDING_REVIEW' },
|
||||
{ label: '已审核', value: 'APPROVED' },
|
||||
{ label: '已驳回', value: 'REJECTED' },
|
||||
{ label: '已出账', value: 'ISSUED' },
|
||||
{ label: '部分结清', value: 'PARTIALLY_PAID' },
|
||||
{ label: '已结清', value: 'PAID' },
|
||||
{ label: '已作废', value: 'VOIDED' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,20 +31,19 @@ const financeStatuses: Record<string, FinanceStatusDefinition> = {
|
||||
export const financeBillStatusOptions = [
|
||||
{ label: '草稿', value: 'DRAFT' },
|
||||
{ label: '待审核', value: 'PENDING_REVIEW' },
|
||||
{ label: '已出账', value: 'ISSUED' },
|
||||
{ label: '已审核', value: 'APPROVED' },
|
||||
{ label: '已驳回', value: 'REJECTED' },
|
||||
{ label: '已出账', value: 'ISSUED' },
|
||||
{ label: '部分结清', value: 'PARTIALLY_PAID' },
|
||||
{ label: '已结清', value: 'PAID' },
|
||||
{ label: '已作废', value: 'VOIDED' },
|
||||
]
|
||||
|
||||
export const financeArapStatusOptions = [
|
||||
...financeBillStatusOptions,
|
||||
{ label: '待结算', value: 'AVAILABLE' },
|
||||
{ label: '结算锁定', value: 'LOCKED' },
|
||||
{ label: '已入账', value: 'BILLED' },
|
||||
{ label: '已登记', value: 'RECORDED' },
|
||||
{ label: '已确认', value: 'CONFIRMED' },
|
||||
{ label: '草稿', value: 'DRAFT' },
|
||||
{ label: '待审核', value: 'PENDING' },
|
||||
{ label: '已审核', value: 'APPROVED' },
|
||||
{ label: '已驳回', value: 'REJECTED' },
|
||||
]
|
||||
|
||||
function normalizeStatus(status: unknown) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
|
||||
export type FinanceBillStatus = 'DRAFT' | 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
|
||||
export type FinanceCostType = 'SINGLE_ORDER' | 'CONTAINER_SHARED'
|
||||
export type FinancePaymentMethod = 'BANK_TRANSFER' | 'CASH' | 'OTHER'
|
||||
export type FinanceFeeDirection = 'RECEIVABLE' | 'PAYABLE'
|
||||
@@ -59,6 +59,7 @@ export interface FinanceFeePoolItem {
|
||||
feeItemName: string
|
||||
amount: number
|
||||
currency: 'CNY'
|
||||
settlementMode?: string | null
|
||||
status: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ describe('InboundTaskList measurement scope', () => {
|
||||
expect(source).toContain('批量审核通过')
|
||||
expect(source).toContain('getMeasurementReviewDetail')
|
||||
expect(source).toContain('approveMeasurement')
|
||||
expect(source).toContain('expectedVersion: detail.inboundVersion')
|
||||
expect(source).not.toContain('expectedVersion: detail.version')
|
||||
expect(source).toContain('approval.initialPricingSucceeded')
|
||||
expect(source).toContain('费用初算未生成,可补价后重新计算')
|
||||
})
|
||||
|
||||
@@ -219,7 +219,7 @@ async function batchApproveMeasurements() {
|
||||
const detail = await getMeasurementReviewDetail(String(row.tenantId), String(row.id))
|
||||
const approval = await approveMeasurement(String(row.tenantId), String(row.id), {
|
||||
measurementId: detail.measurementId,
|
||||
expectedVersion: detail.version,
|
||||
expectedVersion: detail.inboundVersion,
|
||||
})
|
||||
successCount += 1
|
||||
if (!approval.initialPricingSucceeded) {
|
||||
|
||||
@@ -66,7 +66,9 @@ export interface MeasurementReviewDetail {
|
||||
measurementId: string
|
||||
sourceType: string | null
|
||||
measurementStatus: string | null
|
||||
/** 入库单乐观锁版本;审核接口 expectedVersion 使用此字段。 */
|
||||
inboundVersion: number
|
||||
/** 测量批次自身版本,不用于入库单状态审核。 */
|
||||
version: number
|
||||
originalDocumentNo: string | null
|
||||
originalGrossWeightKg: number | null
|
||||
|
||||
@@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance/api/finance'
|
||||
import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees'
|
||||
import ContainerFeesPanel from './ContainerFeesPanel.vue'
|
||||
|
||||
@@ -13,6 +14,9 @@ vi.mock('../api/containerFees', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/containerFees')>()
|
||||
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
|
||||
})
|
||||
vi.mock('@/modules/finance/api/finance', () => ({
|
||||
getFinanceSettlement: vi.fn(), listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(),
|
||||
}))
|
||||
|
||||
const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8')
|
||||
const detail = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
|
||||
@@ -33,6 +37,9 @@ beforeEach(() => {
|
||||
vi.mocked(listContainerFeeSupplierOptions).mockReset().mockResolvedValue([])
|
||||
vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset()
|
||||
vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never)
|
||||
vi.mocked(getFinanceSettlement).mockReset().mockResolvedValue({} as never)
|
||||
vi.mocked(listFinancePayableSettlements).mockReset().mockResolvedValue([])
|
||||
vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([])
|
||||
vi.mocked(uploadContainerFeePaymentVoucher).mockReset().mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' })
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
@@ -140,6 +147,30 @@ describe('ContainerFeesPanel contract', () => {
|
||||
for (const label of ['新增费用', '通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false)
|
||||
})
|
||||
|
||||
it('hides adjustment and void actions for settled fees', async () => {
|
||||
vi.mocked(listContainerFees).mockResolvedValue([
|
||||
{ id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '海运费', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, businessDate: '2026-08-12', amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
|
||||
])
|
||||
vi.mocked(listFinanceReceivableSettlements).mockResolvedValue([{ id: 'ar-1', status: 'PAID', paymentStatus: 'PAID' } as never])
|
||||
vi.mocked(getFinanceSettlement).mockResolvedValue({ lines: [{ sourceType: 'CONTAINER_FEE', sourceFeeId: '1' }] } as never)
|
||||
const wrapper = await mountPanel([
|
||||
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review',
|
||||
'platform:finance:settlement-ar:list',
|
||||
])
|
||||
expect(wrapper.findAll('button').some((button) => button.text() === '调整')).toBe(false)
|
||||
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
|
||||
expect(getFinanceSettlement).toHaveBeenCalledWith('ar-1')
|
||||
})
|
||||
|
||||
it('hides adjustment and void actions for directly paid payable fees', async () => {
|
||||
vi.mocked(listContainerFees).mockResolvedValue([
|
||||
{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '拖车费', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-1', paymentVoucherFileIds: ['9001'], businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
|
||||
])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review'])
|
||||
expect(wrapper.findAll('button').some((button) => button.text() === '调整')).toBe(false)
|
||||
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps receivable usable when supplier permission is missing', async () => {
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'OPERATION', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
|
||||
|
||||
@@ -9,12 +9,14 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance/api/finance'
|
||||
import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
|
||||
|
||||
const props = defineProps<{ planId: string; containerBookingId: string; customers: Array<{ accountId: string; customerName: string | null }> }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const accountId = ref('')
|
||||
const fees = ref<ContainerFeeResource[]>([])
|
||||
const settledFeeIds = ref<Set<string>>(new Set())
|
||||
const feeItems = ref<ApplicableFeeItem[]>([])
|
||||
const suppliers = ref<ContainerFeeSupplierOption[]>([])
|
||||
const costs = ref<ContainerCostDetail[]>([])
|
||||
@@ -98,6 +100,9 @@ function paidSummary(item: ContainerFeeResource) {
|
||||
const evidence = item.paymentReferenceNo || (item.paymentVoucherFileIds?.length ? `${item.paymentVoucherFileIds.length} 个凭证` : '凭证缺失')
|
||||
return `${item.paidAt?.replace('T', ' ').slice(0, 16) || '时间缺失'} · ${paymentMethodLabel(item.paymentMethod)} · ${evidence}`
|
||||
}
|
||||
function isSettled(item: ContainerFeeResource) {
|
||||
return item.settlementMode === 'DIRECT_PAID' || settledFeeIds.value.has(item.id)
|
||||
}
|
||||
async function selectVoucher(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
@@ -153,6 +158,8 @@ async function load() {
|
||||
const feeRows = await listContainerFees(props.planId, requestedAccountId)
|
||||
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
|
||||
fees.value = feeRows
|
||||
await loadSettledFeeIds(requestedAccountId, feeRows, sequence)
|
||||
if (sequence !== loadSequence || requestedAccountId !== accountId.value) return
|
||||
costs.value = []
|
||||
const containerIds = [...new Set(feeRows.map((item) => item.containerBookingId).filter((value): value is string => Boolean(value)))]
|
||||
if (containerIds.some((value) => value !== props.containerBookingId)) throw new Error('柜次费用与装柜摘要的柜次标识不一致,已停止查询财务分摊')
|
||||
@@ -166,6 +173,28 @@ async function load() {
|
||||
finally { if (sequence === loadSequence) loading.value = false }
|
||||
}
|
||||
|
||||
async function loadSettledFeeIds(customerId: string, feeRows: ContainerFeeResource[], sequence: number) {
|
||||
const settled = new Set(feeRows.filter((item) => item.settlementMode === 'DIRECT_PAID').map((item) => item.id))
|
||||
settledFeeIds.value = settled
|
||||
const listRequests: Array<Promise<Awaited<ReturnType<typeof listFinanceReceivableSettlements>> | Awaited<ReturnType<typeof listFinancePayableSettlements>>>> = []
|
||||
if (access.can('platform:finance:settlement-ar:list')) listRequests.push(listFinanceReceivableSettlements(customerId))
|
||||
if (access.can('platform:finance:settlement-ap:list')) listRequests.push(listFinancePayableSettlements(customerId))
|
||||
if (!listRequests.length) return
|
||||
|
||||
const settlementResults = await Promise.allSettled(listRequests)
|
||||
const paidSettlements = settlementResults.flatMap((result) => result.status === 'fulfilled'
|
||||
? result.value.filter((item) => item.status === 'PAID' || item.paymentStatus === 'PAID')
|
||||
: [])
|
||||
const detailResults = await Promise.allSettled(paidSettlements.map((item) => getFinanceSettlement(item.id)))
|
||||
for (const result of detailResults) {
|
||||
if (result.status !== 'fulfilled') continue
|
||||
for (const line of result.value.lines) {
|
||||
if (line.sourceType === 'CONTAINER_FEE' && line.sourceFeeId) settled.add(line.sourceFeeId)
|
||||
}
|
||||
}
|
||||
if (sequence === loadSequence && customerId === accountId.value) settledFeeIds.value = settled
|
||||
}
|
||||
|
||||
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void loadFeeItems() }
|
||||
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return; resetForm(item); editorError.value = null; editorOpen.value = true }
|
||||
async function save() {
|
||||
@@ -230,7 +259,7 @@ function initializeCustomer() { accountId.value = customerOptions.value.length =
|
||||
function clearCustomerContext() {
|
||||
loadSequence += 1
|
||||
supplierLoadSequence += 1
|
||||
fees.value = []; costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = undefined
|
||||
fees.value = []; settledFeeIds.value = new Set(); costs.value = []; feeItems.value = []; suppliers.value = []; error.value = null; feeItemsError.value = null; suppliersError.value = null; loading.value = false; feeItemsLoading.value = false; suppliersLoading.value = false; loadedSupplierCategory.value = undefined
|
||||
editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = ''
|
||||
resetForm()
|
||||
}
|
||||
@@ -263,7 +292,7 @@ onMounted(() => void load())
|
||||
<LoadErrorState v-else-if="error" compact title="柜次费用加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<DataTable v-else :data="fees" :columns="columns" :loading="loading" preference-key="container-fees" row-key="id" empty-text="当前客户暂无柜次费用">
|
||||
<template #toolbar-start><div class="fees-table-title"><strong>费用明细</strong><span>{{ fees.length }} 条</span></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
|
||||
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'" effect="plain">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
|
||||
<template #settlementMode="{ row }">{{ settlementLabel(row as ContainerFeeResource) }}</template>
|
||||
<template #paidSummary="{ row }">{{ paidSummary(row as ContainerFeeResource) }}</template>
|
||||
|
||||
@@ -4,9 +4,14 @@ import {
|
||||
cancelTask,
|
||||
changeTemplateStatus,
|
||||
createNotification,
|
||||
getMyNotification,
|
||||
getMyUnreadNotificationCount,
|
||||
getTask,
|
||||
listMyNotifications,
|
||||
listTasks,
|
||||
listTemplates,
|
||||
markAllMyNotificationsRead,
|
||||
markMyNotificationRead,
|
||||
retryTask,
|
||||
updateTemplate,
|
||||
} from './notificationCenter'
|
||||
@@ -62,4 +67,24 @@ describe('notificationCenter API', () => {
|
||||
expect(result.recipients[0]?.id).toBe('21')
|
||||
expect(result.lastError).toBe('gateway timeout')
|
||||
})
|
||||
it('uses platform inbox paths and normalizes inbox ids', async () => {
|
||||
const inbox = { id: 14, category: 'ORDER', title: 'order notice', summary: 'summary', status: 'UNREAD' as const, sentAt: '2026-08-27T10:00:00', readAt: null }
|
||||
const detail = { ...inbox, body: 'body', sourceType: null, sourceId: null }
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce(envelope({ records: [inbox], page: 1, size: 20, total: 1, pages: 1 }))
|
||||
.mockResolvedValueOnce(envelope(detail))
|
||||
.mockResolvedValueOnce(envelope({ unreadCount: 1 }))
|
||||
vi.mocked(http.put)
|
||||
.mockResolvedValueOnce(envelope(detail))
|
||||
.mockResolvedValueOnce(envelope({ updatedCount: 1 }))
|
||||
const result = await listMyNotifications({ page: 1, size: 20, status: 'UNREAD' })
|
||||
await getMyNotification('14')
|
||||
await markMyNotificationRead('14')
|
||||
await markAllMyNotificationsRead()
|
||||
expect(await getMyUnreadNotificationCount()).toBe(1)
|
||||
expect(result.records[0]?.id).toBe('14')
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/notifications', { params: { page: 1, size: 20, status: 'UNREAD' } })
|
||||
expect(http.put).toHaveBeenNthCalledWith(1, '/platform/v1/notifications/14/read')
|
||||
expect(http.put).toHaveBeenNthCalledWith(2, '/platform/v1/notifications/read-all')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { CreateNotificationRequest, CreateTemplateRequest, NotificationTask, NotificationTaskDetail, NotificationTaskStatus, NotificationTemplate, PageResult, TemplateStatus, UpdateTemplateRequest } from '../types'
|
||||
import type { CreateNotificationRequest, CreateTemplateRequest, NotificationInboxDetail, NotificationInboxItem, NotificationInboxStatus, NotificationTask, NotificationTaskDetail, NotificationTaskStatus, NotificationTemplate, PageResult, TemplateStatus, UpdateTemplateRequest } from '../types'
|
||||
|
||||
type RawId = string | number
|
||||
type RawPage<T> = Omit<PageResult<T>, 'page' | 'size' | 'total' | 'pages'> & { page: RawId; size: RawId; total: RawId; pages: RawId }
|
||||
type RawTemplate = Omit<NotificationTemplate, 'id'> & { id: RawId }
|
||||
type RawTask = Omit<NotificationTask, 'id'> & { id: RawId }
|
||||
type RawTaskDetail = Omit<NotificationTaskDetail, 'id' | 'recipients'> & { id: RawId; recipients: Array<Omit<NotificationTaskDetail['recipients'][number], 'id'> & { id: RawId }> }
|
||||
type RawInbox = Omit<NotificationInboxItem, 'id'> & { id: RawId }
|
||||
type RawInboxDetail = Omit<NotificationInboxDetail, 'id'> & { id: RawId }
|
||||
|
||||
const notificationPath = '/platform/v1/notifications'
|
||||
const templatePath = '/platform/v1/notification-templates'
|
||||
@@ -15,6 +17,8 @@ const page = <T>(value: RawPage<T>): PageResult<T> => ({ ...value, page: Number(
|
||||
const template = (value: RawTemplate): NotificationTemplate => ({ ...value, id: id(value.id) })
|
||||
const task = (value: RawTask): NotificationTask => ({ ...value, id: id(value.id) })
|
||||
const taskDetail = (value: RawTaskDetail): NotificationTaskDetail => ({ ...value, id: id(value.id), recipients: value.recipients.map((recipient) => ({ ...recipient, id: id(recipient.id) })) })
|
||||
const inbox = (value: RawInbox): NotificationInboxItem => ({ ...value, id: id(value.id), summary: value.summary ?? '', readAt: value.readAt ?? null })
|
||||
const inboxDetail = (value: RawInboxDetail): NotificationInboxDetail => ({ ...value, id: id(value.id), summary: value.summary ?? '', readAt: value.readAt ?? null, body: value.body ?? '', sourceType: value.sourceType ?? null, sourceId: value.sourceId ?? null })
|
||||
|
||||
export async function createNotification(payload: CreateNotificationRequest) { const response = await http.post<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(notificationPath, payload); return task(unwrapApiResponse(response)) }
|
||||
export async function listTemplates(query: { page: number; size: number; templateCode?: string; category?: string; title?: string; status?: TemplateStatus }) { const response = await http.get<ApiEnvelope<RawPage<RawTemplate>>, ApiEnvelope<RawPage<RawTemplate>>>(templatePath, { params: query }); const data = unwrapApiResponse(response); return page({ ...data, records: data.records.map(template) }) }
|
||||
@@ -25,3 +29,26 @@ export async function listTasks(query: { page: number; size: number; taskNo?: st
|
||||
export async function getTask(taskId: string) { const response = await http.get<ApiEnvelope<RawTaskDetail>, ApiEnvelope<RawTaskDetail>>(`${taskPath}/${encodeURIComponent(taskId)}`); return taskDetail(unwrapApiResponse(response)) }
|
||||
export async function cancelTask(taskId: string) { const response = await http.put<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(`${taskPath}/${encodeURIComponent(taskId)}/cancel`); return task(unwrapApiResponse(response)) }
|
||||
export async function retryTask(taskId: string) { const response = await http.put<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(`${taskPath}/${encodeURIComponent(taskId)}/retry`); return task(unwrapApiResponse(response)) }
|
||||
|
||||
const inboxPath = '/platform/v1/notifications'
|
||||
export async function listMyNotifications(query: { page: number; size: number; status?: NotificationInboxStatus; category?: string }) {
|
||||
const response = await http.get<ApiEnvelope<RawPage<RawInbox>>, ApiEnvelope<RawPage<RawInbox>>>(inboxPath, { params: query })
|
||||
const data = unwrapApiResponse(response)
|
||||
return page({ ...data, records: data.records.map(inbox) })
|
||||
}
|
||||
export async function getMyNotification(notificationId: string) {
|
||||
const response = await http.get<ApiEnvelope<RawInboxDetail>, ApiEnvelope<RawInboxDetail>>(`${inboxPath}/${encodeURIComponent(notificationId)}`)
|
||||
return inboxDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function markMyNotificationRead(notificationId: string) {
|
||||
const response = await http.put<ApiEnvelope<RawInboxDetail>, ApiEnvelope<RawInboxDetail>>(`${inboxPath}/${encodeURIComponent(notificationId)}/read`)
|
||||
return inboxDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function markAllMyNotificationsRead() {
|
||||
const response = await http.put<ApiEnvelope<{ updatedCount: number }>, ApiEnvelope<{ updatedCount: number }>>(`${inboxPath}/read-all`)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function getMyUnreadNotificationCount() {
|
||||
const response = await http.get<ApiEnvelope<{ unreadCount: number }>, ApiEnvelope<{ unreadCount: number }>>(`${inboxPath}/unread-count`)
|
||||
return unwrapApiResponse(response).unreadCount
|
||||
}
|
||||
|
||||
@@ -18,17 +18,28 @@ const recipientIdsText = ref('')
|
||||
const variables = reactive<Record<string, string>>({})
|
||||
const form = reactive({ tenantId: '0', templateCode: '', recipientType: 'PLATFORM_ROLE' as RecipientType, roleCode: '', scheduledAt: '' })
|
||||
const selectedTemplate = computed(() => props.templates.find((item) => item.templateCode === form.templateCode) ?? null)
|
||||
const scheduled = computed({ get: () => Boolean(form.scheduledAt), set: (value) => { if (!value) form.scheduledAt = '' } })
|
||||
function toDateTimeValue(date: Date) {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
const scheduled = computed({
|
||||
get: () => Boolean(form.scheduledAt),
|
||||
set: (value: boolean) => {
|
||||
if (value && !form.scheduledAt) form.scheduledAt = toDateTimeValue(new Date(Date.now() + 5 * 60 * 1000))
|
||||
if (!value) form.scheduledAt = ''
|
||||
},
|
||||
})
|
||||
const needsIds = computed(() => ['USER', 'MEMBERSHIP'].includes(form.recipientType))
|
||||
const needsRole = computed(() => ['TENANT_ROLE', 'PLATFORM_ROLE'].includes(form.recipientType))
|
||||
const allowedRecipients = computed(() => scope.value === 'platform'
|
||||
? [{ label: '指定平台用户', value: 'USER' }, { label: '平台角色', value: 'PLATFORM_ROLE' }]
|
||||
: [{ label: '指定企业成员', value: 'MEMBERSHIP' }, { label: '企业角色', value: 'TENANT_ROLE' }, { label: '企业全部成员', value: 'TENANT_ALL' }])
|
||||
: [{ label: '指定企业成员', value: 'USER' }, { label: '企业角色', value: 'TENANT_ROLE' }, { label: '企业全部成员', value: 'TENANT_ALL' }])
|
||||
|
||||
watch(scope, (value) => { form.tenantId = value === 'platform' ? '0' : ''; form.recipientType = value === 'platform' ? 'PLATFORM_ROLE' : 'TENANT_ALL'; form.roleCode = ''; recipientIdsText.value = '' })
|
||||
watch(selectedTemplate, (value) => { for (const key of Object.keys(variables)) delete variables[key]; for (const key of value?.variableWhitelist ?? []) variables[key] = '' })
|
||||
|
||||
function reset() { scope.value = 'platform'; Object.assign(form, { tenantId: '0', templateCode: '', recipientType: 'PLATFORM_ROLE', roleCode: '', scheduledAt: '' }); recipientIdsText.value = ''; error.value = null; for (const key of Object.keys(variables)) delete variables[key] }
|
||||
const MAX_LONG_ID = 9_223_372_036_854_775_807n
|
||||
function parseIds() { return [...new Set(recipientIdsText.value.split(/[\s,,]+/).map((value) => value.trim()).filter(Boolean))] }
|
||||
function idempotencyKey() { return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `notification-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
async function submit() {
|
||||
@@ -36,7 +47,7 @@ async function submit() {
|
||||
const recipientIds = parseIds()
|
||||
if (!form.templateCode) return ElMessage.warning('请选择已激活模板')
|
||||
if (scope.value === 'customer' && !form.tenantId) return ElMessage.warning('请选择目标企业')
|
||||
if (needsIds.value && (!recipientIds.length || recipientIds.some((value) => !/^\d+$/.test(value)))) return ElMessage.warning('请输入有效的数字用户或成员 ID')
|
||||
if (needsIds.value && (!recipientIds.length || recipientIds.length > 1000 || recipientIds.some((value) => !/^\d+$/.test(value) || BigInt(value) > MAX_LONG_ID))) return ElMessage.warning('请输入有效的数字用户 ID,单次最多 1000 个且不能超过 19 位')
|
||||
if (needsRole.value && !form.roleCode.trim()) return ElMessage.warning('请输入角色编码')
|
||||
if (Object.values(variables).some((value) => !value.trim())) return ElMessage.warning('请填写全部模板变量')
|
||||
submitting.value = true
|
||||
@@ -56,7 +67,7 @@ async function submit() {
|
||||
<PlatformTenantSelector v-if="scope === 'customer'" v-model="form.tenantId" label="目标企业" />
|
||||
<el-form-item label="消息模板" required><el-select v-model="form.templateCode" filterable placeholder="请选择已激活模板"><el-option v-for="item in templates" :key="item.id" :label="`${item.templateName}(${item.templateCode})`" :value="item.templateCode" /></el-select></el-form-item>
|
||||
<div class="form-grid"><el-form-item label="接收对象" required><el-select v-model="form.recipientType"><el-option v-for="item in allowedRecipients" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item v-if="needsRole" label="角色编码" required><el-input v-model="form.roleCode" maxlength="64" placeholder="请输入服务端角色编码" /></el-form-item></div>
|
||||
<el-form-item v-if="needsIds" label="接收人 ID" required><el-input v-model="recipientIdsText" type="textarea" :rows="2" placeholder="多个数字 ID 使用逗号或换行分隔,最多 1000 个" /></el-form-item>
|
||||
<el-form-item v-if="needsIds" label="接收人用户 ID" required><el-input v-model="recipientIdsText" type="textarea" :rows="2" placeholder="多个用户 ID 使用逗号或换行分隔,最多 1000 个" /></el-form-item>
|
||||
<section v-if="selectedTemplate?.variableWhitelist.length" class="variables"><strong>模板变量</strong><el-form-item v-for="key in selectedTemplate.variableWhitelist" :key="key" :label="key" required><el-input v-model="variables[key]" maxlength="2000" /></el-form-item></section>
|
||||
<el-form-item label="定时发送"><div class="schedule-row"><el-switch v-model="scheduled" aria-label="启用定时发送" /><span>启用</span><el-date-picker v-model="form.scheduledAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择发送时间" :disabled="!scheduled" /></div></el-form-item>
|
||||
</el-form>
|
||||
|
||||
@@ -61,6 +61,13 @@ describe('notification center contract UI', () => {
|
||||
expect(wrapper.text()).not.toContain('存为草稿')
|
||||
})
|
||||
|
||||
it('keeps the scheduled-send switch actionable and clears its date when disabled', () => {
|
||||
const composer = source('./components/MessageComposerDialog.vue')
|
||||
expect(composer).toContain('if (value && !form.scheduledAt)')
|
||||
expect(composer).toContain('if (!value) form.scheduledAt = \'\'')
|
||||
expect(composer).toContain('Date.now() + 5 * 60 * 1000')
|
||||
})
|
||||
|
||||
it('uses the prototype editor width and exposes the real save command', () => {
|
||||
const wrapper = mount(TemplateEditorDialog, {
|
||||
props: { modelValue: true },
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Bell, CheckCheck, Eye, RefreshCcw } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getMyNotification, getMyUnreadNotificationCount, listMyNotifications, markAllMyNotificationsRead, markMyNotificationRead } from '../api/notificationCenter'
|
||||
import { notificationCategoryLabel } from '../presentation'
|
||||
import type { NotificationInboxDetail, NotificationInboxItem, NotificationInboxStatus, PageResult } from '../types'
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const result = ref<PageResult<NotificationInboxItem>>({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
const filters = ref<QueryModel>({ status: '', category: '' })
|
||||
const unreadCount = ref<number | null>(null)
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<NotificationInboxDetail | null>(null)
|
||||
const access = useAccessPreviewStore()
|
||||
const canRead = computed(() => access.can('platform:notification:read'))
|
||||
const canReadAll = computed(() => access.can('platform:notification:read-all'))
|
||||
const queryFields: QueryField[] = [
|
||||
{ key: 'status', type: 'select', label: '消息状态', placeholder: '全部状态', options: [{ label: '未读', value: 'UNREAD' }, { label: '已读', value: 'READ' }] },
|
||||
{ key: 'category', type: 'input', label: '消息分类', placeholder: '请输入消息分类' },
|
||||
]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'title', prop: 'title', label: '消息标题', minWidth: 220 },
|
||||
{ key: 'summary', prop: 'summary', label: '内容摘要', minWidth: 300, showOverflowTooltip: true },
|
||||
{ key: 'category', label: '分类', width: 130, slotName: 'category' },
|
||||
{ key: 'sentAt', label: '发送时间', width: 180, slotName: 'sentAt' },
|
||||
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
|
||||
]
|
||||
|
||||
function statusLabel(status: NotificationInboxStatus) { return status === 'UNREAD' ? '未读' : '已读' }
|
||||
function formatDate(value: string | null) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
function search() { page.value === 1 ? void load() : page.value = 1 }
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
result.value = await listMyNotifications({ page: page.value, size: pageSize.value, status: filters.value.status as NotificationInboxStatus || undefined, category: String(filters.value.category || '').trim() || undefined })
|
||||
unreadCount.value = await getMyUnreadNotificationCount()
|
||||
} catch (reason) {
|
||||
error.value = getApiErrorDetails(reason, '我的消息加载失败')
|
||||
result.value = { records: [], page: page.value, size: pageSize.value, total: 0, pages: 0 }
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
async function openDetail(row: NotificationInboxItem) {
|
||||
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
|
||||
try {
|
||||
detail.value = await getMyNotification(row.id)
|
||||
if (detail.value.status === 'UNREAD' && canRead.value) {
|
||||
detail.value = await markMyNotificationRead(row.id)
|
||||
await load()
|
||||
}
|
||||
} catch (reason) { detailError.value = getApiErrorDetails(reason, '消息详情加载失败') } finally { detailLoading.value = false }
|
||||
}
|
||||
async function markRead(row: NotificationInboxItem) {
|
||||
if (!canRead.value || row.status !== 'UNREAD') return
|
||||
try { await markMyNotificationRead(row.id); ElMessage.success('消息已标记为已读'); await load() } catch (reason) { ElMessage.error(getApiErrorMessage(reason, '标记已读失败')) }
|
||||
}
|
||||
async function markAllRead() {
|
||||
try { await markAllMyNotificationsRead(); ElMessage.success('全部消息已标记为已读'); await load() } catch (reason) { ElMessage.error(getApiErrorMessage(reason, '标记已读失败')) }
|
||||
}
|
||||
watch([page, pageSize], () => void load())
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<ListPageLayout aria-label="我的消息列表">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="我的消息筛选" :fields="queryFields" preference-key="notifications-inbox-query" @search="search" /></template>
|
||||
<template v-if="error" #state><LoadErrorState title="我的消息加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<section v-if="!error" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息">
|
||||
<template #toolbar-actions><el-button v-if="canReadAll && unreadCount" type="primary" plain :disabled="loading" @click="markAllRead"><CheckCheck :size="16" />全部标为已读</el-button></template>
|
||||
<template #pagination-left><span>共 {{ result.total }} 条<span v-if="unreadCount">,未读 {{ unreadCount }} 条</span></span></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as NotificationInboxItem)"><Eye :size="15" />查看</el-button><el-button v-if="canRead && (row as NotificationInboxItem).status === 'UNREAD'" link type="primary" @click="markRead(row as NotificationInboxItem)"><CheckCheck :size="15" />标为已读</el-button></div></template>
|
||||
<template #category="{ row }">{{ notificationCategoryLabel((row as NotificationInboxItem).category) }}</template>
|
||||
<template #sentAt="{ row }">{{ formatDate((row as NotificationInboxItem).sentAt) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="(row as NotificationInboxItem).status === 'UNREAD' ? 'danger' : 'info'">{{ statusLabel((row as NotificationInboxItem).status) }}</el-tag></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
<AppDrawer v-model="detailOpen" title="消息详情" size="min(680px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close>
|
||||
<LoadErrorState v-if="detailError" title="消息详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<article v-else-if="detail" class="notification-detail"><header><h2>{{ detail.title }}</h2><el-tag :type="detail.status === 'UNREAD' ? 'danger' : 'info'">{{ statusLabel(detail.status) }}</el-tag></header><p class="meta">{{ notificationCategoryLabel(detail.category) }} · {{ formatDate(detail.sentAt) }}</p><div class="body">{{ detail.body || '暂无消息内容' }}</div></article>
|
||||
</AppDrawer>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-actions { display: flex; min-height: 32px; align-items: center; gap: 8px; }
|
||||
.table-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.notification-detail header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.notification-detail h2 { margin: 0; color: var(--text-primary, #1f2937); font-size: 20px; line-height: 1.4; }
|
||||
.notification-detail .meta { color: var(--text-secondary, #64748b); font-size: 12px; }
|
||||
.notification-detail .body { padding-top: 16px; border-top: 1px solid var(--border-color, #e5e7eb); color: var(--text-primary, #1f2937); white-space: pre-wrap; line-height: 1.8; }
|
||||
</style>
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const notificationRoutes = [
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'notification-inbox',
|
||||
component: () => import('@/modules/notifications/pages/PlatformNotificationsView.vue'),
|
||||
meta: { title: '我的消息', description: '查看当前平台账号的站内消息', permission: 'platform:notification:view' },
|
||||
},
|
||||
{
|
||||
path: 'notifications/messages',
|
||||
name: 'notification-messages',
|
||||
|
||||
@@ -8,6 +8,10 @@ export interface NotificationTask { id: string; taskNo: string; category: string
|
||||
export interface NotificationTaskRecipient { id: string; displayName: string; appCode: string; status: string; failureReason: string | null; deliveredAt: string | null }
|
||||
export interface NotificationTaskDetail extends Omit<NotificationTask, 'duplicated'> { attemptCount: number; lastError: string | null; recipients: NotificationTaskRecipient[] }
|
||||
|
||||
export type NotificationInboxStatus = 'UNREAD' | 'READ'
|
||||
export interface NotificationInboxItem { id: string; category: string; title: string; summary: string; status: NotificationInboxStatus; sentAt: string; readAt: string | null }
|
||||
export interface NotificationInboxDetail extends NotificationInboxItem { body: string; sourceType: string | null; sourceId: string | null }
|
||||
|
||||
export interface CreateNotificationRequest { tenantId: string; templateCode: string; recipientType: RecipientType; recipientIds?: string[]; roleCode?: string; variables: Record<string, string>; scheduledAt: string | null; idempotencyKey: string; sourceEventId?: string | null }
|
||||
export interface CreateTemplateRequest { templateCode: string; templateName: string; category: string; titleTemplate: string; bodyTemplate: string; variableWhitelist: string[] }
|
||||
export interface UpdateTemplateRequest extends Omit<CreateTemplateRequest, 'templateCode'> { version: number }
|
||||
|
||||
@@ -24,6 +24,11 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
expect(source).not.toContain('cancelOceanBooking')
|
||||
})
|
||||
|
||||
it('invalidates the cached booking list after deleting a draft', () => {
|
||||
expect(source).toContain("workspace.bumpCacheEpoch('ocean-bookings')")
|
||||
expect(source).toContain("await router.replace('/ocean-bookings')")
|
||||
})
|
||||
|
||||
it('saves incomplete DRAFT data and sends null for blank nullable fields', () => {
|
||||
expect(source).toContain('保存草稿')
|
||||
expect(source).toContain('channelCode: form.data.channelCode?.trim() || null')
|
||||
|
||||
@@ -316,6 +316,7 @@ async function removeDraft() {
|
||||
actionSaving.value = true
|
||||
await deleteOceanBookingDraft(id.value, detail.value.version)
|
||||
ElMessage.success('订舱草稿已删除')
|
||||
workspace.bumpCacheEpoch('ocean-bookings')
|
||||
await router.replace('/ocean-bookings')
|
||||
} catch (cause) {
|
||||
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
|
||||
|
||||
@@ -47,6 +47,16 @@ describe('TruckAppointmentForm interaction contract', () => {
|
||||
expect(source).not.toContain('const relationText = computed')
|
||||
})
|
||||
|
||||
it('loads readonly detail options and preserves relation snapshots outside the first page', () => {
|
||||
expect(source).toContain('onMounted(() => { void loadOptions() })')
|
||||
expect(source).toContain('const selectedContainerFallback = computed')
|
||||
expect(source).toContain('const selectedBookingFallback = computed')
|
||||
expect(source).toContain('const selectedWarehouseFallback = computed')
|
||||
expect(source).toContain('v-if="selectedContainerFallback"')
|
||||
expect(source).toContain('v-if="selectedBookingFallback"')
|
||||
expect(source).toContain('v-if="selectedWarehouseFallback"')
|
||||
})
|
||||
|
||||
it('keeps booking, cabinet and loading plan optional and only uses them for completion hints', () => {
|
||||
expect(source).toContain('订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约')
|
||||
expect(source).toContain('class="relation-note"')
|
||||
|
||||
@@ -53,6 +53,27 @@ const selectableCarriers = computed(() => carriers.value.filter((item) => (
|
||||
item.status === 'ACTIVE'
|
||||
)))
|
||||
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId && item.status === 'RELEASED') || null)
|
||||
const selectedContainerFallback = computed(() => {
|
||||
const id = draft.containerBookingId
|
||||
const snapshot = props.resource
|
||||
if (!id || containers.value.some((item) => String(item.id) === String(id)) || String(snapshot?.containerBookingId) !== String(id)) return null
|
||||
return {
|
||||
id: String(id),
|
||||
label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 集装箱号:${snapshot?.containerNo || '待录入'}`,
|
||||
}
|
||||
})
|
||||
const selectedBookingFallback = computed(() => {
|
||||
const id = draft.oceanBookingId
|
||||
const snapshot = props.resource
|
||||
if (!id || bookings.value.some((item) => String(item.id) === String(id)) || String(snapshot?.oceanBookingId) !== String(id)) return null
|
||||
return { id: String(id), label: snapshot?.oceanBookingNo || '订舱号缺失' }
|
||||
})
|
||||
const selectedWarehouseFallback = computed(() => {
|
||||
const id = draft.warehouseId
|
||||
const snapshot = props.resource
|
||||
if (!id || selectableWarehouses.value.some((item) => String(item.id) === String(id)) || String(snapshot?.warehouseId) !== String(id)) return null
|
||||
return { id: String(id), label: snapshot?.warehouseName || '当前仓库(需重新选择)' }
|
||||
})
|
||||
function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }
|
||||
function selectCustomer(customerId: string) { const customer = selectableCustomers.value.find((item) => String(item.id) === customerId); draft.customerId = customer?.id ? String(customer.id) : null; draft.customerName = customer ? customerLabel(customer) : null; derivedCustomerSource.value = null; relationWarning.value = '' }
|
||||
function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null }
|
||||
@@ -115,7 +136,7 @@ async function loadOptions() {
|
||||
async function validate() { return formRef.value?.validate() }
|
||||
async function submitForm() { if (await formRef.value?.validate()) emit('submit') }
|
||||
defineExpose({ validate })
|
||||
onMounted(() => { if (!props.readonly) void loadOptions() })
|
||||
onMounted(() => { void loadOptions() })
|
||||
watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.length) void loadOptions() })
|
||||
</script>
|
||||
|
||||
@@ -131,9 +152,9 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="预计装箱时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计装箱时间" style="width:100%" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="提单号"><el-input v-model="draft.billOfLadingNo" :disabled="Boolean(selectedReleasedBooking)" maxlength="128" placeholder="草稿可暂不填写,派车前必填" /><div v-if="selectedReleasedBooking" class="field-hint">已从放舱订舱回填并锁定</div></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">可选关联</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="关联柜次(可选)"><el-select :model-value="draft.containerBookingId" filterable clearable placeholder="可选择柜次辅助补全" :loading="optionsLoading" no-data-text="暂无可关联柜次" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-for="item in containers" :key="item.id" :label="`柜次号:${item.cabinetNo || item.bookingNo || '待生成'} / 集装箱号:${item.containerNo || '待录入'}`" :value="item.id" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="关联订舱(可选)"><el-select :model-value="draft.oceanBookingId" filterable clearable placeholder="可选择订舱辅助补全" :loading="optionsLoading" no-data-text="暂无可关联订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-for="item in bookings" :key="item.id" :label="item.bookingNo || '订舱号缺失'" :value="item.id" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-select v-model="draft.warehouseId" filterable clearable placeholder="请选择仓库" :loading="optionsLoading" style="width:100%"><el-option v-for="item in selectableWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="关联柜次(可选)"><el-select :model-value="draft.containerBookingId" filterable clearable placeholder="可选择柜次辅助补全" :loading="optionsLoading" no-data-text="暂无可关联柜次" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-if="selectedContainerFallback" :key="selectedContainerFallback.id" :label="selectedContainerFallback.label" :value="selectedContainerFallback.id" /><el-option v-for="item in containers" :key="item.id" :label="`柜次号:${item.cabinetNo || item.bookingNo || '待生成'} / 集装箱号:${item.containerNo || '待录入'}`" :value="item.id" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="关联订舱(可选)"><el-select :model-value="draft.oceanBookingId" filterable clearable placeholder="可选择订舱辅助补全" :loading="optionsLoading" no-data-text="暂无可关联订舱" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-if="selectedBookingFallback" :key="selectedBookingFallback.id" :label="selectedBookingFallback.label" :value="selectedBookingFallback.id" /><el-option v-for="item in bookings" :key="item.id" :label="item.bookingNo || '订舱号缺失'" :value="item.id" /></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-select v-model="draft.warehouseId" filterable clearable placeholder="请选择仓库" :loading="optionsLoading" style="width:100%"><el-option v-if="selectedWarehouseFallback" :key="selectedWarehouseFallback.id" :label="selectedWarehouseFallback.label" :value="selectedWarehouseFallback.id" /><el-option v-for="item in selectableWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item></el-col>
|
||||
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="派车单号"><el-input :model-value="resource.appointmentNo || ''" disabled /></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-divider content-position="left">运输与时间计划</el-divider></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" placeholder="请输入车队联系人" /></el-form-item></el-col>
|
||||
|
||||
@@ -52,4 +52,10 @@ describe('TruckAppointmentDetailView status contract', () => {
|
||||
expect(source).toContain('主详情数据不受影响')
|
||||
expect(source).toContain('v-if="relatedError"')
|
||||
})
|
||||
|
||||
it('reloads a cached detail after an error without showing stale resource data', () => {
|
||||
expect(source).toContain("resource.value = null; history.value = []; relatedError.value = ''")
|
||||
expect(source).toContain('onActivated(() => {')
|
||||
expect(source).toContain('if (error.value || resource.value?.id !== id.value) void load()')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
@@ -16,6 +16,7 @@ import { truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointm
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, customerId: null, carrierId: null, plannedLoadingAt: null })
|
||||
const history = ref<TruckAppointmentStatusHistory[]>([])
|
||||
const relatedError = ref('')
|
||||
let activatedOnce = false
|
||||
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
|
||||
const relationSnapshot = computed(() => resource.value ? [resource.value.customerName, resource.value.oceanBookingNo, resource.value.cabinetNo, resource.value.containerNo, resource.value.warehouseName].filter(Boolean).join(' / ') || '无历史关联' : '')
|
||||
async function loadRelated() {
|
||||
@@ -29,7 +30,7 @@ async function loadRelated() {
|
||||
if (histories.status === 'fulfilled') history.value = histories.value
|
||||
if (attachments.status === 'rejected' || histories.status === 'rejected') relatedError.value = '附件或状态历史暂时未能加载,主详情数据不受影响,可稍后刷新重试。'
|
||||
}
|
||||
async function load() { loading.value = true; error.value = null; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0 }); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
|
||||
async function load() { loading.value = true; error.value = null; resource.value = null; history.value = []; relatedError.value = ''; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0 }); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
|
||||
async function submit() { saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } }
|
||||
const actionByTarget: Partial<Record<TruckAppointmentStatus, TruckAppointmentAction>> = { PENDING_DISPATCH: 'SUBMIT', DISPATCHED: 'DISPATCH', PICKUP_IN_PROGRESS: 'PICKUP', ARRIVED_WAREHOUSE: 'ARRIVE', LOADING: 'LOAD', DEPARTED: 'DEPART', COMPLETED: 'COMPLETE', EXCEPTION: 'EXCEPTION', CANCELLED: 'CANCEL' }
|
||||
const primaryTargetByStatus: Partial<Record<TruckAppointmentStatus, TruckAppointmentStatus>> = { DRAFT: 'PENDING_DISPATCH', PENDING_DISPATCH: 'DISPATCHED', RESCHEDULED: 'DISPATCHED', DISPATCHED: 'PICKUP_IN_PROGRESS', PICKUP_IN_PROGRESS: 'ARRIVED_WAREHOUSE', ARRIVED_WAREHOUSE: 'LOADING', LOADING: 'DEPARTED', DEPARTED: 'COMPLETED' }
|
||||
@@ -52,6 +53,13 @@ async function changeStatus(status: TruckAppointmentStatus) {
|
||||
try { await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, form.expectedVersion || 0) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
onActivated(() => {
|
||||
if (activatedOnce) {
|
||||
if (error.value || resource.value?.id !== id.value) void load()
|
||||
return
|
||||
}
|
||||
activatedOnce = true
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage as="div" class="truck-detail-page">
|
||||
|
||||
@@ -133,6 +133,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain("{ key: 'customerRemark', prop: 'customerRemark', label: '客户备注'")
|
||||
expect(source).toContain("{ key: 'latestInternalNote', prop: 'latestInternalNote', label: '内部备注'")
|
||||
expect(source.match(/<LineClampText v-if="column\.key === 'remark'"/g)).toHaveLength(3)
|
||||
expect(source).toContain("detail.summary.internalRemark || detail.summary.latestInternalNote || '—'")
|
||||
})
|
||||
|
||||
it('uses ListPageLayout regions for one 16px query-to-table gap', () => {
|
||||
@@ -237,6 +238,11 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).not.toContain("value.replaceAll('_', ' ')")
|
||||
})
|
||||
|
||||
it('uses the inbound measurement girth formula for cargo dimensions', () => {
|
||||
expect(source).toContain('formatNumber(length + 2 * (width + height))')
|
||||
expect(source).not.toContain('formatNumber(2 * (width + height))')
|
||||
})
|
||||
|
||||
it('binds dimension and weight evidence to their formal server records without mixing galleries', () => {
|
||||
expect(source).toContain('<el-table :data="measurementDetail?.items || []" row-key="id"')
|
||||
expect(source).toContain('<el-table-column label="箱号" prop="boxCode"')
|
||||
|
||||
@@ -439,9 +439,12 @@ function orderStatusLabel(value: string | null | undefined) {
|
||||
function cargoValue(cargo: Record<string, unknown>, key: string) {
|
||||
const value = cargo[key]
|
||||
if (key === 'circumferenceCm') {
|
||||
const length = Number(cargo.lengthCm)
|
||||
const width = Number(cargo.widthCm)
|
||||
const height = Number(cargo.heightCm)
|
||||
return Number.isFinite(width) && Number.isFinite(height) ? formatNumber(2 * (width + height)) : '—'
|
||||
return Number.isFinite(length) && Number.isFinite(width) && Number.isFinite(height)
|
||||
? formatNumber(length + 2 * (width + height))
|
||||
: '—'
|
||||
}
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
if (typeof value === 'number') return formatNumber(value)
|
||||
@@ -1017,7 +1020,7 @@ onBeforeUnmount(() => {
|
||||
<div class="wide"><dt>PC 端下单文件</dt><dd>{{ detail.summary.pcOrderFiles?.join('、') || '—' }}</dd></div>
|
||||
<div><dt>快递公司</dt><dd>{{ detail.summary.expressCompany || detail.order.order?.expressCompany || '—' }}</dd></div>
|
||||
<div><dt>快递单号</dt><dd>{{ detail.summary.expressTrackingNo || detail.order.order?.expressTrackingNo || '—' }}</dd></div>
|
||||
<div class="wide"><dt>客服内部备注</dt><dd>{{ detail.summary.internalRemark || '—' }}</dd></div>
|
||||
<div class="wide"><dt>客服内部备注</dt><dd>{{ detail.summary.internalRemark || detail.summary.latestInternalNote || '—' }}</dd></div>
|
||||
<div><dt>创建人</dt><dd>{{ detail.summary.createdBy || '—' }}</dd></div>
|
||||
<div><dt>下一步</dt><dd>{{ canApprove(detail.summary) ? '审核订单' : nextActionBoundary(detail.summary) || '—' }}</dd></div>
|
||||
</dl>
|
||||
|
||||
@@ -66,6 +66,13 @@ describe('API error diagnostics', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('translates notification recipient error codes into actionable Chinese messages', () => {
|
||||
expect(getApiErrorDetails(new ApiResponseError('NOTIFICATION_RECIPIENT_FORBIDDEN', 'A0400')).message)
|
||||
.toBe('角色编码不存在或当前账号无权使用该角色,请检查后重试')
|
||||
expect(getApiErrorDetails(new ApiResponseError('NOTIFICATION_RECIPIENT_EMPTY', 'A0400')).message)
|
||||
.toBe('该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员')
|
||||
})
|
||||
|
||||
it('preserves a backend credential error for an explicit login request', () => {
|
||||
const error = new ApiResponseError(
|
||||
'用户名或密码错误',
|
||||
|
||||
+11
-2
@@ -53,6 +53,15 @@ let authRefreshHandler: AuthRefreshHandler | null = null
|
||||
let authSessionExpiredHandler: AuthSessionExpiredHandler | null = null
|
||||
let activeRefresh: Promise<string> | null = null
|
||||
|
||||
const businessErrorMessages: Record<string, string> = {
|
||||
NOTIFICATION_RECIPIENT_FORBIDDEN: '角色编码不存在或当前账号无权使用该角色,请检查后重试',
|
||||
NOTIFICATION_RECIPIENT_EMPTY: '该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员',
|
||||
}
|
||||
|
||||
function friendlyBusinessMessage(message?: string | null) {
|
||||
return (message && businessErrorMessages[message]) || message || undefined
|
||||
}
|
||||
|
||||
export function registerAuthRefreshHandler(handler: AuthRefreshHandler) {
|
||||
authRefreshHandler = handler
|
||||
}
|
||||
@@ -134,7 +143,7 @@ export function getApiErrorDetails(
|
||||
? '当前账号无权访问该数据,请联系管理员确认权限'
|
||||
: error.code.startsWith('S05')
|
||||
? '服务暂时不可用,请稍后重试'
|
||||
: error.message
|
||||
: friendlyBusinessMessage(error.message) || '接口请求失败'
|
||||
return { message, code: error.code, requestId: error.requestId, traceId: error.traceId }
|
||||
}
|
||||
if (axios.isAxiosError(error)) {
|
||||
@@ -156,7 +165,7 @@ export function getApiErrorDetails(
|
||||
? '服务暂时不可用,请稍后重试'
|
||||
: undefined
|
||||
return {
|
||||
message: statusMessage || response?.message || response?.error || fallback,
|
||||
message: statusMessage || friendlyBusinessMessage(response?.message || response?.error) || fallback,
|
||||
code: response?.code || (error.response?.status ? `HTTP_${error.response.status}` : undefined),
|
||||
requestId: response?.requestId || undefined,
|
||||
traceId: response?.traceId || undefined,
|
||||
|
||||
@@ -40,11 +40,13 @@ const props = withDefaults(defineProps<{
|
||||
searchText?: string
|
||||
resetText?: string
|
||||
searchOnReset?: boolean
|
||||
searchOnClear?: boolean
|
||||
loading?: boolean
|
||||
}>(), {
|
||||
searchText: '查询',
|
||||
resetText: '重置',
|
||||
searchOnReset: true,
|
||||
searchOnClear: true,
|
||||
loading: false,
|
||||
})
|
||||
|
||||
@@ -146,6 +148,10 @@ function search() {
|
||||
emit('search', model.value)
|
||||
}
|
||||
|
||||
function clearInput() {
|
||||
if (props.searchOnClear) search()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (props.loading) return
|
||||
for (const field of props.fields) model.value[field.key] = resetValue(field)
|
||||
@@ -182,7 +188,7 @@ defineExpose({ search, reset })
|
||||
clearable
|
||||
v-bind="field.props"
|
||||
@update:model-value="updateField(field.key, $event)"
|
||||
@clear="search"
|
||||
@clear="clearInput"
|
||||
@keyup.enter.exact="searchFromTextInput"
|
||||
>
|
||||
<template #prefix><Search :size="16" /></template>
|
||||
|
||||
Reference in New Issue
Block a user