This commit is contained in:
2026-09-30 14:01:58 +08:00
parent f14e1a2ea3
commit 8c51e9c1cf
109 changed files with 1525 additions and 651 deletions
@@ -456,7 +456,7 @@ describe('ContainersView', () => {
expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'") expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
expect(source).toContain("{ key: 'view', label: '查看', ariaLabel: containerViewLabel(row), priority: 500") expect(source).toContain("{ key: 'view', label: '查看', ariaLabel: containerViewLabel(row), priority: 500")
expect(source).toContain("hasAction(row, 'EDIT_CONTAINER')") expect(source).toContain("hasAction(row, 'EDIT_CONTAINER')")
expect(source).toContain('<RowActions :actions="containerRowActions(row as ContainerWorkbenchRow)"') expect(source).toContain('<RowActions text-actions :actions="containerRowActions(row as ContainerWorkbenchRow)"')
expect(source).not.toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'") expect(source).not.toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
expect(source).toContain('货柜基础资料不提供柜号写入') expect(source).toContain('货柜基础资料不提供柜号写入')
expect(source).toContain('containerNo: optional(detail.value?.containerNo || null)') expect(source).toContain('containerNo: optional(detail.value?.containerNo || null)')
@@ -465,7 +465,8 @@ describe('ContainersView', () => {
expect(source).toContain("row.planStatus === 'EDITING' ? canSmartLoading.value") expect(source).toContain("row.planStatus === 'EDITING' ? canSmartLoading.value")
expect(source).not.toContain('canSmartLoading && row.containerNo') expect(source).not.toContain('canSmartLoading && row.containerNo')
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel') expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"') expect(source).toContain(':loading="loading" preference-key="container-loading-table"')
expect(source).toContain('appearance="integrated" :stripe="false"')
}) })
it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => { it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => {
@@ -579,7 +580,7 @@ describe('ContainersView', () => {
try { try {
await flushPromises() await flushPromises()
const actions = wrapper.find('.el-table__body .row-actions') const actions = wrapper.find('.el-table__body .row-actions')
expect(actions.findAll('button').map((button) => button.text())).toEqual(['查看', '编辑', ({ EDITING: '装柜', LOADING: '预装清单', COMPLETED: '装柜清单' })[planStatus], '']) expect(actions.findAll('button').map((button) => button.text())).toEqual(['查看', '编辑', ({ EDITING: '装柜', LOADING: '预装清单', COMPLETED: '装柜清单' })[planStatus], '更多'])
await actions.find('button[aria-label="更多货柜操作"]').trigger('click') await actions.find('button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises() await flushPromises()
const link = Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item')) const link = Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
@@ -730,8 +730,12 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<AdminPage class="admin-density" as="div"> <AdminPage class="admin-density" as="div">
<section class="container-page"> <section class="container-page">
<ListPageLayout :ariaLabel="isDomestic ? '送柜报关工作台' : '清关提柜工作台'"> <ListPageLayout :ariaLabel="isDomestic ? '送柜报关工作台' : '清关提柜工作台'">
<template #toolbar>
<div class="container-query-panel">
<section id="container-workbench-results" ><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-start><h1>{{ isDomestic ? '货柜列表' : '清关提柜' }}</h1></template>
<template #filters>
<div class="container-query-panel integrated-query-group">
<div class="container-scope-tabs density-tabs" role="tablist" aria-label="货柜完结状态"> <div class="container-scope-tabs density-tabs" role="tablist" aria-label="货柜完结状态">
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { active: scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button> <button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { active: scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button>
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'HISTORY'" :tabindex="scope === 'HISTORY' ? 0 : -1" :class="['container-scope-tab', { active: scope === 'HISTORY' }]" @click="scope = 'HISTORY'">已完结</button> <button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'HISTORY'" :tabindex="scope === 'HISTORY' ? 0 : -1" :class="['container-scope-tab', { active: scope === 'HISTORY' }]" @click="scope = 'HISTORY'">已完结</button>
@@ -739,13 +743,13 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<QueryToolbar v-model="filters" class="container-query-toolbar" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" /> <QueryToolbar v-model="filters" class="container-query-toolbar" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
</div> </div>
</template> </template>
<template v-if="loadError" #state> <template #notices><template v-if="loadError">
<LoadErrorState title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState> <LoadErrorState title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
</template> </template></template>
<section v-if="!loadError" id="container-workbench-results" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-actions><ContainerSimulationCreate dense v-if="isDomestic && canCreate" :load-warehouse-options="loadSimulationWarehouses" @created="loadRows" /></template> <template #toolbar-actions><ContainerSimulationCreate dense v-if="isDomestic && canCreate" :load-warehouse-options="loadSimulationWarehouses" @created="loadRows" /></template>
<template #actions="{ row }"><RowActions :actions="containerRowActions(row as ContainerWorkbenchRow)" more-label="更多货柜操作" @action="handleContainerRowAction($event, row as ContainerWorkbenchRow)" /></template> <template #actions="{ row }"><RowActions text-actions :actions="containerRowActions(row as ContainerWorkbenchRow)" more-label="更多货柜操作" @action="handleContainerRowAction($event, row as ContainerWorkbenchRow)" /></template>
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template> <template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template> <template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template> <template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
@@ -764,8 +768,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template> <template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template> <template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.actualPortArrivalAt) }}</template> <template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.actualPortArrivalAt) }}</template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" /> <OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />
@@ -819,7 +822,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
</template> </template>
<style scoped> <style scoped>
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); }.container-scope-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; scrollbar-width: thin; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); } .container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-scope-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; scrollbar-width: thin; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
.container-query-panel { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; } .container-query-panel { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; }
.container-query-toolbar :deep(.query-toolbar__tail) { width: 100%; flex: 0 0 100%; } .container-query-toolbar :deep(.query-toolbar__tail) { width: 100%; flex: 0 0 100%; }
.container-page .table-panel { width: 100%; max-width: 100%; min-width: 0; } .container-page .table-panel { width: 100%; max-width: 100%; min-width: 0; }
+2 -2
View File
@@ -5,7 +5,7 @@ export const containerLoadingRoutes = [
path: 'container-loading/domestic', path: 'container-loading/domestic',
name: 'domestic-container-workbench', name: 'domestic-container-workbench',
component: () => import('./pages/DomesticContainersView.vue'), component: () => import('./pages/DomesticContainersView.vue'),
meta: { title: '货柜列表', description: '管理国内送柜、装柜、出口报关与出仓', permission: 'platform:firstleg:container:list' }, meta: { showPageHeading: false, title: '货柜列表', description: '管理国内送柜、装柜、出口报关与出仓', permission: 'platform:firstleg:container:list' },
}, },
{ {
path: 'container-loading/domestic/:containerId', path: 'container-loading/domestic/:containerId',
@@ -23,7 +23,7 @@ export const containerLoadingRoutes = [
path: 'container-loading/overseas', path: 'container-loading/overseas',
name: 'overseas-container-workbench', name: 'overseas-container-workbench',
component: () => import('./pages/OverseasContainersView.vue'), component: () => import('./pages/OverseasContainersView.vue'),
meta: { title: '清关提柜', description: '管理海运轨迹、目的港清关、提柜与还空', permission: 'platform:firstleg:terminal-drayage:list' }, meta: { showPageHeading: false, title: '清关提柜', description: '管理海运轨迹、目的港清关、提柜与还空', permission: 'platform:firstleg:terminal-drayage:list' },
}, },
{ {
path: 'container-loading/overseas/:containerId', path: 'container-loading/overseas/:containerId',
+11 -9
View File
@@ -1060,23 +1060,25 @@ onMounted(() => { void loadCustomers() })
<AdminPage as="div" class="customers-page admin-density customer-density"> <AdminPage as="div" class="customers-page admin-density customer-density">
<OperationErrorToast :details="passwordError || impersonationError || archiveError" /> <OperationErrorToast :details="passwordError || impersonationError || archiveError" />
<ListPageLayout aria-label="客户管理"> <ListPageLayout aria-label="客户管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户筛选" :fields="queryFields" preference-key="customers-query" @search="applyFilters" /></template>
<template v-if="loadError" #state>
<section :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户" class="customer-list-table" border>
<template #toolbar-start><h1>客户列表</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户筛选" :fields="queryFields" preference-key="customers-query" @search="applyFilters" /></template>
<template #notices><template v-if="loadError">
<LoadErrorState title="客户列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadCustomers"> <LoadErrorState title="客户列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadCustomers">
<template #details><ApiErrorMeta :details="loadError" /></template> <template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState> </LoadErrorState>
</template> </template></template>
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading"> <template #before-results><div v-if="reviewLoadError" class="review-sync-error" role="alert"><div><ShieldX :size="17" /><span>{{ reviewLoadError.message }}</span></div><el-button link type="primary" @click="reloadPendingReviews">重新加载待审核客户</el-button></div></template>
<div v-if="reviewLoadError" class="review-sync-error" role="alert"><div><ShieldX :size="17" /><span>{{ reviewLoadError.message }}</span></div><el-button link type="primary" @click="reloadPendingReviews">重新加载待审核客户</el-button></div>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户" class="customer-list-table" :stripe="true" border>
<template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers">导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate">新增客户</el-button></div></template> <template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers">导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate">新增客户</el-button></div></template>
<template #actions="{ row }"><RowActions class="density-action" :actions="customerRowActions(row as PlatformTenantResource)" :more-label="customerMoreActionsLabel(row as PlatformTenantResource)" @action="handleCustomerRowAction(row as PlatformTenantResource, $event)" /></template> <template #actions="{ row }"><RowActions text-actions class="density-action" :actions="customerRowActions(row as PlatformTenantResource)" :more-label="customerMoreActionsLabel(row as PlatformTenantResource)" @action="handleCustomerRowAction(row as PlatformTenantResource, $event)" /></template>
<template #company="{ row }"><strong class="customer-company-name">{{ (row as PlatformTenantResource).tenantName }}</strong></template> <template #company="{ row }"><strong class="customer-company-name">{{ (row as PlatformTenantResource).tenantName }}</strong></template>
<template #status="{ row }"><el-tag :type="statusMeta((row as PlatformTenantResource).status).type" effect="light">{{ statusMeta((row as PlatformTenantResource).status).label }}</el-tag></template> <template #status="{ row }"><el-tag :type="statusMeta((row as PlatformTenantResource).status).type" effect="light">{{ statusMeta((row as PlatformTenantResource).status).label }}</el-tag></template>
<template #orderCount="{ row }">{{ access.can('platform:order:list') ? (orderCounts[String((row as PlatformTenantResource).id)] ?? '—') : '—' }}</template> <template #orderCount="{ row }">{{ access.can('platform:order:list') ? (orderCounts[String((row as PlatformTenantResource).id)] ?? '—') : '—' }}</template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个客户</span></template> <template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个客户</span></template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog" :loading="editorLoading" :confirm-loading="saving" destroy-on-close> <AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog" :loading="editorLoading" :confirm-loading="saving" destroy-on-close>
@@ -69,11 +69,15 @@ onMounted(() => { void load() })
<AdminPage> <AdminPage>
<div class="business-page overseas-fees-page admin-density customer-density"> <div class="business-page overseas-fees-page admin-density customer-density">
<ListPageLayout ariaLabel="海外仓阶梯费用列表"> <ListPageLayout ariaLabel="海外仓阶梯费用列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="queryFields" ariaLabel="海外仓阶梯费用筛选" preference-key="overseas-storage-fees-query" @search="search" /></template>
<template v-if="error" #state><section class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section></template>
<section v-if="!error" class="surface-panel table-panel"> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" border empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" border stripe empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load"> <template #toolbar-start><h1>海外仓阶梯费用</h1></template>
<template #actions="{ row }"><el-button v-if="access.can('platform:overseas-storage-fee:edit')" class="density-action" size="small" plain @click="openEdit(row as OverseasStorageFeeConfig)">编辑</el-button></template> <template #filters><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外仓阶梯费用筛选" preference-key="overseas-storage-fees-query" @search="search" /></template>
<template #notices><template v-if="error"><section class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section></template></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="access.can('platform:overseas-storage-fee:edit')" class="density-action" size="small" plain @click="openEdit(row as OverseasStorageFeeConfig)">编辑</el-button></div></template>
<template #storage="{ row }"> <template #storage="{ row }">
<dl class="tier-list" aria-label="仓租阶梯单价,币种见本行币种列"> <dl class="tier-list" aria-label="仓租阶梯单价,币种见本行币种列">
<div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'STORAGE')" :key="tier.id" class="tier-item"><dt>{{ label(tier) }}:</dt><dd>{{ tier.unitPrice }}</dd></div> <div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'STORAGE')" :key="tier.id" class="tier-item"><dt>{{ label(tier) }}:</dt><dd>{{ tier.unitPrice }}</dd></div>
@@ -85,8 +89,7 @@ onMounted(() => { void load() })
</dl> </dl>
</template> </template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个客户</span></template> <template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个客户</span></template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="dialogOpen" title="编辑海外仓阶梯费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"> <AppDialog class="admin-density-dialog customer-density-dialog" v-model="dialogOpen" title="编辑海外仓阶梯费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
<el-form label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><div class="group-heading"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(index === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div><el-table :data="group" size="small" border stripe><el-table-column label="操作" width="88" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(row as OverseasStorageFeeTier)">删除</el-button></template></el-table-column><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></template></el-table-column></el-table></div></div></el-form> <el-form label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><div class="group-heading"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(index === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div><el-table :data="group" size="small" border stripe><el-table-column label="操作" width="88" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(row as OverseasStorageFeeTier)">删除</el-button></template></el-table-column><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></template></el-table-column></el-table></div></div></el-form>
+2 -2
View File
@@ -5,13 +5,13 @@ export const customerRoutes = [
path: 'customers', path: 'customers',
name: 'customers', name: 'customers',
component: () => import('@/modules/customers/pages/CustomersView.vue'), component: () => import('@/modules/customers/pages/CustomersView.vue'),
meta: { title: '客户管理', description: '维护客户企业资料、合作状态和注册审核', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:review'] }, meta: { title: '客户管理', showPageHeading: false, description: '维护客户企业资料、合作状态和注册审核', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:review'] },
}, },
{ {
path: 'customers/overseas-storage-fees', path: 'customers/overseas-storage-fees',
name: 'overseas-storage-fees', name: 'overseas-storage-fees',
component: () => import('@/modules/customers/pages/OverseasStorageFeesView.vue'), component: () => import('@/modules/customers/pages/OverseasStorageFeesView.vue'),
meta: { title: '海外仓阶梯费用', description: '维护客户仓租和退货仓储费阶梯', permission: 'platform:overseas-storage-fee:list' }, meta: { title: '海外仓阶梯费用', showPageHeading: false, description: '维护客户仓租和退货仓储费阶梯', permission: 'platform:overseas-storage-fee:list' },
}, },
{ {
path: 'customers/:tenantId', path: 'customers/:tenantId',
@@ -8,7 +8,7 @@ describe('DomesticContainersView', () => {
it('gates create actions with backend permissions', () => { expect(source).toContain("access.can('platform:domestic-container:inbound:create')"); expect(source).toContain("access.can('platform:domestic-container:outbound:create')") }) it('gates create actions with backend permissions', () => { expect(source).toContain("access.can('platform:domestic-container:inbound:create')"); expect(source).toContain("access.can('platform:domestic-container:outbound:create')") })
it('loads only active domestic warehouse candidates and surfaces errors', () => { expect(source).toContain("listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })"); expect(source).toContain('国内仓候选加载失败') }) it('loads only active domestic warehouse candidates and surfaces errors', () => { expect(source).toContain("listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })"); expect(source).toContain('国内仓候选加载失败') })
it('keeps idempotency key stable through retry and handles conflicts by refreshing inventory', () => { expect(source).toContain('idempotencyKey.value'); expect(source).toContain("submitError.value.code === 'A0409'"); expect(source).toContain('await loadCandidates()'); expect(source).not.toContain('localStorage') }) it('keeps idempotency key stable through retry and handles conflicts by refreshing inventory', () => { expect(source).toContain('idempotencyKey.value'); expect(source).toContain("submitError.value.code === 'A0409'"); expect(source).toContain('await loadCandidates()'); expect(source).not.toContain('localStorage') })
it('reuses the order-list tab panel and keyboard interaction', () => { expect(source).toContain('class="business-query-panel"'); expect(source).toContain('role="tablist"'); expect(source).toContain('role="tab"'); expect(source).toContain('handleTabKeydown'); expect(source).toContain(".status-tab.is-active::after{background:var(--cc-primary)}"); expect(source).not.toContain('<el-tabs') }) it('reuses the order-list tab panel and keyboard interaction', () => { expect(source).toContain('class="business-query-panel integrated-query-group"'); expect(source).toContain('role="tablist"'); expect(source).toContain('role="tab"'); expect(source).toContain('handleTabKeydown'); expect(source).toContain(".status-tab.is-active::after{background:var(--cc-primary)}"); expect(source).not.toContain('<el-tabs') })
it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") }) it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") })
it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') }) it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') })
it('keeps all four inventory facts in the approved compact summary', () => { it('keeps all four inventory facts in the approved compact summary', () => {
@@ -74,7 +74,12 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
<template><AdminPage as="div" class="domestic-containers-page admin-density"> <template><AdminPage as="div" class="domestic-containers-page admin-density">
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState> <LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><template #toolbar><div class="business-query-panel"><div class="status-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)"><span class="density-tab-icon" aria-hidden="true">{{ tab.icon }}</span>{{ tab.label }}</button></div><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template><template v-if="loadError" #state><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template><section v-if="!loadError" id="domestic-container-results" class="surface-panel table-panel"><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'"><template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" class="density-action" size="small" @click="viewInventory(row as ContainerOrder | InventoryRecord)">查看</el-button><span v-else>—</span></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></section></ListPageLayout> <ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><section id="domestic-container-results" ><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'">
<template #toolbar-start><h1>整柜业务</h1></template>
<template #filters><div class="business-query-panel integrated-query-group"><div class="status-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)"><span class="density-tab-icon" aria-hidden="true">{{ tab.icon }}</span>{{ tab.label }}</button></div><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template>
<template #notices><template v-if="loadError"><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #before-results><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div></template>
<template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" class="density-action" size="small" @click="viewInventory(row as ContainerOrder | InventoryRecord)">查看</el-button><span v-else>—</span></div></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></section></ListPageLayout>
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog admin-density-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit"><LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState><el-form ref="formRef" :model="currentForm" :rules="rules" label-position="top"><el-row :gutter="16"><template v-if="dialogType === 'inbound'"><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入入库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :max="999999999999.9999" :precision="4" controls-position="right" placeholder="请输入体积" /></el-form-item></el-col></template><template v-else><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称"><el-input v-model="outboundForm.cargoName" readonly /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入出库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="可用库存"><el-input :model-value="`${outboundForm.availableBoxes} 箱`" readonly /></el-form-item></el-col><el-col v-if="candidateError" :xs="24"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></el-col></template><el-col :xs="24" :sm="12"><el-form-item :label="dialogType === 'inbound' ? '入库时间' : '出库时间'" prop="operationTime"><el-date-picker v-model="currentForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template> <AppDialog v-model="dialogOpen" class="domestic-container-create-dialog admin-density-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit"><LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState><el-form ref="formRef" :model="currentForm" :rules="rules" label-position="top"><el-row :gutter="16"><template v-if="dialogType === 'inbound'"><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入入库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :max="999999999999.9999" :precision="4" controls-position="right" placeholder="请输入体积" /></el-form-item></el-col></template><template v-else><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称"><el-input v-model="outboundForm.cargoName" readonly /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入出库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="可用库存"><el-input :model-value="`${outboundForm.availableBoxes} 箱`" readonly /></el-form-item></el-col><el-col v-if="candidateError" :xs="24"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></el-col></template><el-col :xs="24" :sm="12"><el-form-item :label="dialogType === 'inbound' ? '入库时间' : '出库时间'" prop="operationTime"><el-date-picker v-model="currentForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template>
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{overflow:hidden;border:1px solid var(--cc-border);border-radius:var(--admin-radius, 4px);background:var(--admin-surface, #fff)}.status-tabs{display:flex;max-width:100%;overflow-x:auto;margin-bottom:0;border-bottom:1px solid var(--admin-table-border, #e5e7eb);background:#f9fafb;scrollbar-width:thin}.status-tab{position:relative;flex:0 0 auto;display:inline-flex;align-items:center;gap:var(--admin-space-2, 6px);min-height:var(--admin-tab-height, 38px);padding:0 var(--admin-space-6, 16px);border:0;background:transparent;color:var(--cc-muted);font:inherit;font-size:var(--admin-font-body, 13px);cursor:pointer}.status-tab::after{position:absolute;right:12px;bottom:0;left:12px;height:3px;background:transparent;content:''}.status-tab:hover{color:var(--cc-primary);background:#f6f9fd}.status-tab:focus-visible{outline:2px solid var(--cc-primary);outline-offset:-2px}.status-tab.is-active{color:var(--cc-primary);font-weight:600}.status-tab.is-active::after{background:var(--cc-primary)}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--cc-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.status-tab{min-height:46px;padding:0 14px}.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.status-tab{padding:0 10px;font-size:13px}.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style> <style scoped>.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.status-tabs{display:flex;max-width:100%;overflow-x:auto;margin-bottom:0;border-bottom:1px solid var(--admin-table-border, #e5e7eb);background:#f9fafb;scrollbar-width:thin}.status-tab{position:relative;flex:0 0 auto;display:inline-flex;align-items:center;gap:var(--admin-space-2, 6px);min-height:var(--admin-tab-height, 38px);padding:0 var(--admin-space-6, 16px);border:0;background:transparent;color:var(--cc-muted);font:inherit;font-size:var(--admin-font-body, 13px);cursor:pointer}.status-tab::after{position:absolute;right:12px;bottom:0;left:12px;height:3px;background:transparent;content:''}.status-tab:hover{color:var(--cc-primary);background:#f6f9fd}.status-tab:focus-visible{outline:2px solid var(--cc-primary);outline-offset:-2px}.status-tab.is-active{color:var(--cc-primary);font-weight:600}.status-tab.is-active::after{background:var(--cc-primary)}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--cc-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.status-tab{min-height:46px;padding:0 14px}.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.status-tab{padding:0 10px;font-size:13px}.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
+1 -1
View File
@@ -1,5 +1,5 @@
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
export const domesticContainerRoutes = [ export const domesticContainerRoutes = [
{ path: 'domestic-containers', name: 'domestic-containers', component: () => import('./pages/DomesticContainersView.vue'), meta: { title: '整柜业务', description: '管理国内仓整柜入库、出库与库存', permission: 'platform:domestic-container:list' } }, { path: 'domestic-containers', name: 'domestic-containers', component: () => import('./pages/DomesticContainersView.vue'), meta: { showPageHeading: false, title: '整柜业务', description: '管理国内仓整柜入库、出库与库存', permission: 'platform:domestic-container:list' } },
{ path: 'domestic-containers/inventories/:inventoryId', name: 'domestic-container-inventory-detail', component: () => import('./pages/DomesticContainerInventoryDetailView.vue'), meta: { title: '整柜库存详情', description: '查看整柜库存快照与不可变交易记录', permission: 'platform:domestic-container:list', showPageHeading: false } }, { path: 'domestic-containers/inventories/:inventoryId', name: 'domestic-container-inventory-detail', component: () => import('./pages/DomesticContainerInventoryDetailView.vue'), meta: { title: '整柜库存详情', description: '查看整柜库存快照与不可变交易记录', permission: 'platform:domestic-container:list', showPageHeading: false } },
] satisfies RouteRecordRaw[] ] satisfies RouteRecordRaw[]
@@ -233,10 +233,10 @@ describe('DownloadCenterView', () => {
const wrapper = mount(DownloadCenterView, { global: { plugins: [createPinia(), ElementPlus] } }) const wrapper = mount(DownloadCenterView, { global: { plugins: [createPinia(), ElementPlus] } })
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.find('.table-panel').attributes('aria-busy')).toBe('true') expect(wrapper.find('.el-table').attributes('aria-busy')).toBe('true')
resolveRequest({ records: [], page: 1, size: 20, total: 0, pages: 0 }) resolveRequest({ records: [], page: 1, size: 20, total: 0, pages: 0 })
await flushPromises() await flushPromises()
expect(wrapper.find('.table-panel').attributes('aria-busy')).toBe('false') expect(wrapper.find('.el-table').attributes('aria-busy')).toBe('false')
}) })
it('does not schedule another refresh after an in-flight load finishes on an unmounted page', async () => { it('does not schedule another refresh after an in-flight load finishes on an unmounted page', async () => {
@@ -266,25 +266,28 @@ onBeforeUnmount(() => {
<AdminPage class="admin-density"> <AdminPage class="admin-density">
<div class="business-page download-center-page"> <div class="business-page download-center-page">
<ListPageLayout ariaLabel="下载任务列表"> <ListPageLayout ariaLabel="下载任务列表">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="下载任务筛选" :fields="queryFields" preference-key="downloads-query" @search="applyFilters" />
</template>
<template v-if="loadError" #state>
<section :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="downloads-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无符合筛选条件的下载任务" aria-label="下载任务列表">
<template #toolbar-start><h1>下载中心</h1></template>
<template #filters>
<QueryToolbar v-model="filters" ariaLabel="下载任务筛选" :fields="queryFields" preference-key="downloads-query" @search="applyFilters" />
</template>
<template #notices><template v-if="loadError">
<section class="surface-panel download-error" role="alert"> <section class="surface-panel download-error" role="alert">
<div><strong>下载任务暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div> <div><strong>下载任务暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div>
<el-button type="primary" :loading="loading" @click="loadTasks()">重新加载</el-button> <el-button type="primary" :loading="loading" @click="loadTasks()">重新加载</el-button>
</section> </section>
</template> </template></template>
<template #before-results><div v-if="syncWarning" class="download-sync-warning" role="status">
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
<div v-if="syncWarning" class="download-sync-warning" role="status">
<span>自动刷新暂时失败,正在保留当前任务并继续重试:{{ syncWarning.message }}</span> <span>自动刷新暂时失败,正在保留当前任务并继续重试:{{ syncWarning.message }}</span>
<ApiErrorMeta :details="syncWarning" /> <ApiErrorMeta :details="syncWarning" />
</div> </div></template>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="downloads-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无符合筛选条件的下载任务" aria-label="下载任务列表">
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个任务</span></template> <template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个任务</span></template>
<template #actions="{ row }"><div class="table-actions density-actions"> <template #actions="{ row }"><div class="table-actions density-actions row-actions admin-text-actions">
<el-button data-tone="neutral" class="density-action" v-if="(row as PlatformExportTask)?.id" @click="openDetail(row as PlatformExportTask)">详情</el-button> <el-button data-tone="neutral" class="density-action" v-if="(row as PlatformExportTask)?.id" @click="openDetail(row as PlatformExportTask)">详情</el-button>
<el-button data-tone="neutral" class="density-action" v-if="(row as PlatformExportTask).status === 'SUCCEEDED'" :loading="downloadingId === (row as PlatformExportTask).id" @click="downloadTask(row as PlatformExportTask)">下载</el-button> <el-button data-tone="neutral" class="density-action" v-if="(row as PlatformExportTask).status === 'SUCCEEDED'" :loading="downloadingId === (row as PlatformExportTask).id" @click="downloadTask(row as PlatformExportTask)">下载</el-button>
<el-button data-tone="neutral" class="density-action" v-if="canRetry(row as PlatformExportTask)" :loading="retryingId === (row as PlatformExportTask).id" @click="retryTask(row as PlatformExportTask)">{{ retryLabel(row as PlatformExportTask) }}</el-button> <el-button data-tone="neutral" class="density-action" v-if="canRetry(row as PlatformExportTask)" :loading="retryingId === (row as PlatformExportTask).id" @click="retryTask(row as PlatformExportTask)">{{ retryLabel(row as PlatformExportTask) }}</el-button>
@@ -296,8 +299,7 @@ onBeforeUnmount(() => {
<template #requestedAt="{ row }">{{ formatDate((row as PlatformExportTask).requestedAt) }}</template> <template #requestedAt="{ row }">{{ formatDate((row as PlatformExportTask).requestedAt) }}</template>
<template #completedAt="{ row }">{{ formatDate((row as PlatformExportTask).completedAt) }}</template> <template #completedAt="{ row }">{{ formatDate((row as PlatformExportTask).completedAt) }}</template>
<template #expiresAt="{ row }">{{ formatDate((row as PlatformExportTask).expiresAt) }}</template> <template #expiresAt="{ row }">{{ formatDate((row as PlatformExportTask).expiresAt) }}</template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<AppDialog class="admin-density-dialog" <AppDialog class="admin-density-dialog"
+1 -1
View File
@@ -5,6 +5,6 @@ export const downloadRoutes = [
path: 'downloads', path: 'downloads',
name: 'download-center', name: 'download-center',
component: () => import('@/modules/downloads/pages/DownloadCenterView.vue'), component: () => import('@/modules/downloads/pages/DownloadCenterView.vue'),
meta: { title: '下载中心', description: '查看异步导出任务并下载已生成文件', permission: 'platform:download:list' }, meta: { showPageHeading: false, title: '下载中心', description: '查看异步导出任务并下载已生成文件', permission: 'platform:download:list' },
}, },
] satisfies RouteRecordRaw[] ] satisfies RouteRecordRaw[]
@@ -140,13 +140,13 @@ describe('CarrierRatesView price-book source contract', () => {
const queryToolbarBlock = source.slice(source.indexOf('<QueryToolbar'), queryToolbarEnd + 2) const queryToolbarBlock = source.slice(source.indexOf('<QueryToolbar'), queryToolbarEnd + 2)
expect(queryToolbarBlock).not.toContain('#actions') expect(queryToolbarBlock).not.toContain('#actions')
expect(source).toContain('<template #toolbar-actions><el-button v-if="canEdit" type="primary" @click="openUpload">') expect(source).toContain('<template #toolbar-actions><el-button v-if="canEdit" type="primary" @click="openUpload">')
expect(source).not.toContain('#toolbar-start') expect(source).toContain('<template #toolbar-start><h1>船期价格表</h1></template>')
expect(source).toContain('<template #pagination-left>共 {{ rows.length }} 个版本') expect(source).toContain('<template #pagination-left>共 {{ rows.length }} 个版本')
}) })
it('keeps list spacing and error state mutually exclusive', () => { it('keeps list spacing and error state mutually exclusive', () => {
expect(source).toContain('<template v-if="loadError" #state>') expect(source).toContain('<template #notices>')
expect(source).toContain('<section v-if="!loadError" class="surface-panel table-panel"') expect(source).toContain(':results-visible="!loadError"')
expect(source).not.toContain('<template #state>') expect(source).not.toContain('<template #state>')
}) })
@@ -319,30 +319,11 @@ onMounted(() => {
<template> <template>
<AdminPage as="div" class="price-book-page admin-density" spacing="normal"> <AdminPage as="div" class="price-book-page admin-density" spacing="normal">
<ListPageLayout aria-label="承运商报价版本管理"> <ListPageLayout aria-label="承运商报价版本管理">
<template #toolbar>
<QueryToolbar
v-model="filters"
class="surface-panel"
ariaLabel="船期价格表筛选"
:fields="queryFields"
preference-key="price-book-query"
@search="applyQuery"
/>
</template>
<template v-if="loadError" #state>
<LoadErrorState
title="船期价格表版本加载失败"
:message="loadErrorMessage"
:loading="loading"
@retry="loadPriceBooks()"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template>
<section v-if="!loadError" class="surface-panel table-panel" aria-live="polite">
<DataTable
<section aria-live="polite"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page" v-model:page="page"
v-model:page-size="pageSize" v-model:page-size="pageSize"
:data="pagedRows" :data="pagedRows"
@@ -353,13 +334,33 @@ onMounted(() => {
:page-sizes="[10, 20, 50]" :page-sizes="[10, 20, 50]"
row-key="id" row-key="id"
border border
stripe
aria-label="船期价格表版本列表" aria-label="船期价格表版本列表"
empty-text="暂无船期价格表版本" empty-text="暂无船期价格表版本"
> >
<template #toolbar-start><h1>船期价格表</h1></template>
<template #filters>
<QueryToolbar
v-model="filters"
ariaLabel="船期价格表筛选"
:fields="queryFields"
preference-key="price-book-query"
@search="applyQuery"
/>
</template>
<template #notices><template v-if="loadError">
<LoadErrorState
title="船期价格表版本加载失败"
:message="loadErrorMessage"
:loading="loading"
@retry="loadPriceBooks()"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template></template>
<template #toolbar-actions><el-button v-if="canEdit" type="primary" @click="openUpload"><FileUp :size="16" />上传价格表</el-button></template> <template #toolbar-actions><el-button v-if="canEdit" type="primary" @click="openUpload"><FileUp :size="16" />上传价格表</el-button></template>
<template #actions="{ row }"> <template #actions="{ row }"><div class="table-actions density-actions row-actions admin-text-actions">
<div class="table-actions density-actions">
<el-button data-tone="neutral" class="density-action" size="small" @click="openDetail(row as PlatformPriceBookVersion)">{{ detailActionLabel(row as PlatformPriceBookVersion) }}</el-button> <el-button data-tone="neutral" class="density-action" size="small" @click="openDetail(row as PlatformPriceBookVersion)">{{ detailActionLabel(row as PlatformPriceBookVersion) }}</el-button>
<el-button data-tone="neutral" class="density-action" <el-button data-tone="neutral" class="density-action"
size="small" size="small"
@@ -375,8 +376,7 @@ onMounted(() => {
:loading="voidingIds.has((row as PlatformPriceBookVersion).id)" :loading="voidingIds.has((row as PlatformPriceBookVersion).id)"
@click="voidPending(row as PlatformPriceBookVersion)" @click="voidPending(row as PlatformPriceBookVersion)"
>作废</el-button> >作废</el-button>
</div> </div></template>
</template>
<template #version="{ row }"><div class="version-cell"><strong>V{{ (row as PlatformPriceBookVersion).versionNo }}</strong></div></template> <template #version="{ row }"><div class="version-cell"><strong>V{{ (row as PlatformPriceBookVersion).versionNo }}</strong></div></template>
<template #scope="{ row }"><el-tag :type="(row as PlatformPriceBookVersion).customerId === '0' ? 'info' : 'warning'" effect="light">{{ priceBookScopeLabel((row as PlatformPriceBookVersion).customerId) }}</el-tag></template> <template #scope="{ row }"><el-tag :type="(row as PlatformPriceBookVersion).customerId === '0' ? 'info' : 'warning'" effect="light">{{ priceBookScopeLabel((row as PlatformPriceBookVersion).customerId) }}</el-tag></template>
<template #status="{ row }"><el-tag :type="priceBookStatusMeta((row as PlatformPriceBookVersion).status).type" effect="light">{{ priceBookStatusMeta((row as PlatformPriceBookVersion).status).label }}</el-tag></template> <template #status="{ row }"><el-tag :type="priceBookStatusMeta((row as PlatformPriceBookVersion).status).type" effect="light">{{ priceBookStatusMeta((row as PlatformPriceBookVersion).status).label }}</el-tag></template>
@@ -384,8 +384,7 @@ onMounted(() => {
<template #issues="{ row }"><div class="issue-counts"><span :class="{ 'is-blocking': (row as PlatformPriceBookVersion).blockingErrorCount }">阻断 {{ (row as PlatformPriceBookVersion).blockingErrorCount }}</span><span>警告 {{ (row as PlatformPriceBookVersion).warningCount }}</span></div></template> <template #issues="{ row }"><div class="issue-counts"><span :class="{ 'is-blocking': (row as PlatformPriceBookVersion).blockingErrorCount }">阻断 {{ (row as PlatformPriceBookVersion).blockingErrorCount }}</span><span>警告 {{ (row as PlatformPriceBookVersion).warningCount }}</span></div></template>
<template #empty><el-empty description="暂无船期价格表版本" /></template> <template #empty><el-empty description="暂无船期价格表版本" /></template>
<template #pagination-left>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中 · {{ pendingCount }} 个待确认</template> <template #pagination-left>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中 · {{ pendingCount }} 个待确认</template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<AppDialog class="admin-density-dialog" <AppDialog class="admin-density-dialog"
+1 -1
View File
@@ -5,7 +5,7 @@ export const fbaPricingRoutes = [
path: 'first-leg-pricing/carrier-rates', path: 'first-leg-pricing/carrier-rates',
name: 'fba-carrier-rates', name: 'fba-carrier-rates',
component: () => import('./carrier-rates/pages/CarrierRatesView.vue'), component: () => import('./carrier-rates/pages/CarrierRatesView.vue'),
meta: { meta: { showPageHeading: false,
title: '船期价格表', title: '船期价格表',
description: '上传、确认并查询平台公共船期价格表版本', description: '上传、确认并查询平台公共船期价格表版本',
permission: 'firstleg:fba-pricing:rate:view', permission: 'firstleg:fba-pricing:rate:view',
@@ -69,7 +69,7 @@ describe('FeeItemsView public master data and access', () => {
expect(wrapper.find('.data-table').exists()).toBe(true) expect(wrapper.find('.data-table').exists()).toBe(true)
expect(wrapper.find('.query-toolbar__actions').text()).not.toContain('新增费用项目') expect(wrapper.find('.query-toolbar__actions').text()).not.toContain('新增费用项目')
expect(wrapper.find('.data-table__business-actions').text()).toContain('新增费用项目') expect(wrapper.find('.data-table__business-actions').text()).toContain('新增费用项目')
expect(wrapper.find('.data-table__toolbar-start').exists()).toBe(false) expect(wrapper.get('.data-table__toolbar-start h1').text()).toBe('费用项目')
expect(wrapper.find('.data-table__pagination-left').text()).toContain('平台公共主数据 · 共 0 项') expect(wrapper.find('.data-table__pagination-left').text()).toContain('平台公共主数据 · 共 0 项')
expect(wrapper.find('.list-page-layout__state').exists()).toBe(false) expect(wrapper.find('.list-page-layout__state').exists()).toBe(false)
expect(wrapper.text()).toContain('平台公共主数据') expect(wrapper.text()).toContain('平台公共主数据')
@@ -86,8 +86,8 @@ describe('FeeItemsView public master data and access', () => {
vi.mocked(listPlatformFeeItems).mockRejectedValueOnce(new Error('Gateway unavailable')) vi.mocked(listPlatformFeeItems).mockRejectedValueOnce(new Error('Gateway unavailable'))
const wrapper = await mountView(['firstleg:fee-item:view']) const wrapper = await mountView(['firstleg:fee-item:view'])
expect(wrapper.find('.list-page-layout__state').exists()).toBe(true) expect(wrapper.find('.data-table__notices').exists()).toBe(true)
expect(wrapper.find('.table-panel').exists()).toBe(false) expect(wrapper.find('.el-table').exists()).toBe(false)
expect(wrapper.text()).toContain('费用项目加载失败') expect(wrapper.text()).toContain('费用项目加载失败')
expect(wrapper.text()).not.toContain('Gateway unavailable') expect(wrapper.text()).not.toContain('Gateway unavailable')
}) })
@@ -109,7 +109,8 @@ describe('FeeItemsView public master data and access', () => {
expect(actions).not.toMatch(/<(?:el-dropdown|Pencil|Power|Eye|Trash2|Download|CircleX)\b/) expect(actions).not.toMatch(/<(?:el-dropdown|Pencil|Power|Eye|Trash2|Download|CircleX)\b/)
}) })
it('keeps the prototype table treatment and mobile form fallback without inventing a pricing mode', () => { it('keeps the prototype table treatment and mobile form fallback without inventing a pricing mode', () => {
expect(source).toContain(':page-sizes="[20, 50, 100]"\n border\n stripe') expect(source).toContain('appearance="integrated" :stripe="false"')
expect(source).toContain(':page-sizes="[20, 50, 100]"')
expect(source).toContain('@media (max-width: 720px)') expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('.direction-grid, .form-grid.two-cols { grid-template-columns: 1fr; }') expect(source).toContain('.direction-grid, .form-grid.two-cols { grid-template-columns: 1fr; }')
expect(source).not.toContain('pricingMode') expect(source).not.toContain('pricingMode')
+29 -28
View File
@@ -340,31 +340,11 @@ onMounted(() => {
<el-result v-if="!canView" icon="warning" title="无权查看费用项目" sub-title="请联系管理员开通费用项目查看权限" /> <el-result v-if="!canView" icon="warning" title="无权查看费用项目" sub-title="请联系管理员开通费用项目查看权限" />
<ListPageLayout v-else aria-label="费用项目管理"> <ListPageLayout v-else aria-label="费用项目管理">
<template #toolbar>
<QueryToolbar
v-model="filters"
class="surface-panel"
ariaLabel="费用项目筛选"
:fields="queryFields"
:loading="loading"
preference-key="fee-items-query"
@search="applyFilters"
/>
</template>
<template v-if="loadError" #state>
<LoadErrorState
title="费用项目加载失败"
:message="loadError.message"
:loading="loading"
@retry="loadFeeItems"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template>
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page" v-model:page="page"
v-model:page-size="pageSize" v-model:page-size="pageSize"
:data="rows" :data="rows"
@@ -373,12 +353,34 @@ onMounted(() => {
:total="total" :total="total"
:page-sizes="[20, 50, 100]" :page-sizes="[20, 50, 100]"
border border
stripe
preference-key="fee-items-table" preference-key="fee-items-table"
aria-label="费用项目列表" aria-label="费用项目列表"
row-key="id" row-key="id"
empty-text="暂无符合条件的费用项目" empty-text="暂无符合条件的费用项目"
> >
<template #toolbar-start><h1>费用项目</h1></template>
<template #filters>
<QueryToolbar
v-model="filters"
ariaLabel="费用项目筛选"
:fields="queryFields"
:loading="loading"
preference-key="fee-items-query"
@search="applyFilters"
/>
</template>
<template #notices><template v-if="loadError">
<LoadErrorState
title="费用项目加载失败"
:message="loadError.message"
:loading="loading"
@retry="loadFeeItems"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template></template>
<template #toolbar-actions> <template #toolbar-actions>
<el-button <el-button
v-if="access.can('firstleg:fee-item:create')" v-if="access.can('firstleg:fee-item:create')"
@@ -387,7 +389,7 @@ onMounted(() => {
@click="openCreate" @click="openCreate"
><Plus :size="16" />新增费用项目</el-button> ><Plus :size="16" />新增费用项目</el-button>
</template> </template>
<template #actions="{ row }"> <template #actions="{ row }"><div class="row-actions density-actions admin-text-actions">
<div v-if="access.can('firstleg:fee-item:edit') || access.can('firstleg:fee-item:delete')" class="table-actions density-actions"> <div v-if="access.can('firstleg:fee-item:edit') || access.can('firstleg:fee-item:delete')" class="table-actions density-actions">
<el-button data-tone="neutral" class="density-action" v-if="access.can('firstleg:fee-item:edit')" size="small" :disabled="loading || editorLoading" @click="openEdit(row as PlatformFeeItemResource)">编辑</el-button> <el-button data-tone="neutral" class="density-action" v-if="access.can('firstleg:fee-item:edit')" size="small" :disabled="loading || editorLoading" @click="openEdit(row as PlatformFeeItemResource)">编辑</el-button>
<el-button class="density-action" <el-button class="density-action"
@@ -402,7 +404,7 @@ onMounted(() => {
<el-button data-tone="danger" class="density-action" v-if="access.can('firstleg:fee-item:delete')" size="small" :disabled="loading" @click="requestDelete(row as PlatformFeeItemResource)">删除</el-button> <el-button data-tone="danger" class="density-action" v-if="access.can('firstleg:fee-item:delete')" size="small" :disabled="loading" @click="requestDelete(row as PlatformFeeItemResource)">删除</el-button>
</div> </div>
<span v-else class="muted-value">仅查看</span> <span v-else class="muted-value">仅查看</span>
</template> </div></template>
<template #feeItem="{ row }"><div class="fee-item-name"><strong>{{ (row as PlatformFeeItemResource).feeTypeName }}</strong> <template #feeItem="{ row }"><div class="fee-item-name"><strong>{{ (row as PlatformFeeItemResource).feeTypeName }}</strong>
<!-- <small>{{ (row as PlatformFeeItemResource).feeCode || '未分配编码' }}</small> --> <!-- <small>{{ (row as PlatformFeeItemResource).feeCode || '未分配编码' }}</small> -->
</div></template> </div></template>
@@ -419,8 +421,7 @@ onMounted(() => {
</el-empty> </el-empty>
</template> </template>
<template #pagination-left>平台公共主数据 · 共 {{ total }} 项</template> <template #pagination-left>平台公共主数据 · 共 {{ total }} 项</template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<AppDrawer class="admin-density admin-density-drawer" <AppDrawer class="admin-density admin-density-drawer"
+1 -1
View File
@@ -5,7 +5,7 @@ export const feeItemRoutes = [
path: 'master-data/fee-items', path: 'master-data/fee-items',
name: 'fee-items', name: 'fee-items',
component: () => import('@/modules/fee-items/pages/FeeItemsView.vue'), component: () => import('@/modules/fee-items/pages/FeeItemsView.vue'),
meta: { meta: { showPageHeading: false,
title: '费用项目', title: '费用项目',
description: '统一维护平台公共费用项目及其适用范围', description: '统一维护平台公共费用项目及其适用范围',
permission: 'firstleg:fee-item:view', permission: 'firstleg:fee-item:view',
@@ -113,7 +113,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(page).toContain("{ key: 'deduction', label: '扣款', priority: 200") expect(page).toContain("{ key: 'deduction', label: '扣款', priority: 200")
expect(page).toContain("{ key: 'credit', label: '授信', priority: 100") expect(page).toContain("{ key: 'credit', label: '授信', priority: 100")
expect(page).toContain("{ key: 'terms', label: '账期', visible: canUpdateTerms.value") expect(page).toContain("{ key: 'terms', label: '账期', visible: canUpdateTerms.value")
expect(page).toContain('<RowActions :actions="accountActions(row as FinanceAccountSummary)"') expect(page).toContain('<RowActions text-actions :actions="accountActions(row as FinanceAccountSummary)"')
}) })
it('shows authoritative CNY wallet balances and independently permitted actions', () => { it('shows authoritative CNY wallet balances and independently permitted actions', () => {
@@ -125,7 +125,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
it.each([ it.each([
[[], ['详情'], false], [[], ['详情'], false],
[['platform:finance:customer-terms:view', 'platform:finance:customer-terms:update'], ['详情', '账期'], false], [['platform:finance:customer-terms:view', 'platform:finance:customer-terms:update'], ['详情', '账期'], false],
[['platform:finance:customer-account:adjust'], ['详情', '充值', '扣款', ''], true], [['platform:finance:customer-account:adjust'], ['详情', '充值', '扣款', '更多'], true],
])('folds account operations only when more than three are available: %j', async (extraPermissions, labels, overflow) => { ])('folds account operations only when more than three are available: %j', async (extraPermissions, labels, overflow) => {
const wrapper = await mountAccountsView(['platform:finance:customer-account:list', ...extraPermissions]) const wrapper = await mountAccountsView(['platform:finance:customer-account:list', ...extraPermissions])
const actions = wrapper.get('.el-table__body-wrapper .density-actions') const actions = wrapper.get('.el-table__body-wrapper .density-actions')
@@ -133,8 +133,8 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(actions.find('.el-dropdown').exists()).toBe(overflow) expect(actions.find('.el-dropdown').exists()).toBe(overflow)
if (overflow) { if (overflow) {
const trigger = actions.get('button[aria-label^="更多客户账户操作"]') const trigger = actions.get('button[aria-label^="更多客户账户操作"]')
expect(trigger.text()).toBe('') expect(trigger.text()).toBe('更多')
expect(trigger.find('svg').exists()).toBe(true) expect(trigger.find('svg').exists()).toBe(false)
} }
wrapper.unmount() wrapper.unmount()
}) })
@@ -436,12 +436,16 @@ onBeforeUnmount(clearVoucherPreview)
<AdminPage as="div" class="finance-account-page admin-density" spacing="normal"> <AdminPage as="div" class="finance-account-page admin-density" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问客户账户" sub-title="请联系管理员开通客户账户查看权限" /> <el-result v-if="!canView" icon="warning" title="无权访问客户账户" sub-title="请联系管理员开通客户账户查看权限" />
<ListPageLayout v-else-if="!detailOpen" aria-label="客户账户"> <ListPageLayout v-else-if="!detailOpen" aria-label="客户账户">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户账户筛选" :fields="queryFields" preference-key="finance-customer-accounts-query" @search="search" /></template>
<template v-if="stateError" #state><LoadErrorState title="客户账户加载失败" :message="stateError.message" :loading="loading || customersLoading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<section v-if="!stateError" class="surface-panel table-panel"> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!stateError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()"> <template #toolbar-start><h1>客户账户</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户账户筛选" :fields="queryFields" preference-key="finance-customer-accounts-query" @search="search" /></template>
<template #notices><template v-if="stateError"><LoadErrorState title="客户账户加载失败" :message="stateError.message" :loading="loading || customersLoading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="canExport" :loading="accountExportBusy" :disabled="loading || accountExportBusy" @click="exportAccounts"><Download :size="16" />导出Excel</el-button></template> <template #toolbar-actions><el-button v-if="canExport" :loading="accountExportBusy" :disabled="loading || accountExportBusy" @click="exportAccounts"><Download :size="16" />导出Excel</el-button></template>
<template #actions="{ row }"><RowActions :actions="accountActions(row as FinanceAccountSummary)" :more-label="`更多客户账户操作 · ${(row as FinanceAccountSummary).customerName || '当前客户'}`" @action="handleAccountAction($event, row as FinanceAccountSummary)" /></template> <template #actions="{ row }"><RowActions text-actions :actions="accountActions(row as FinanceAccountSummary)" :more-label="`更多客户账户操作 · ${(row as FinanceAccountSummary).customerName || '当前客户'}`" @action="handleAccountAction($event, row as FinanceAccountSummary)" /></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template> <template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template>
<template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template> <template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template>
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template> <template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
@@ -449,8 +453,7 @@ onBeforeUnmount(clearVoucherPreview)
<template #pendingRechargeCount="{ row }"><el-tag v-if="row.pendingRechargeCount" type="warning">{{ row.pendingRechargeCount }} 笔</el-tag><span v-else>—</span></template> <template #pendingRechargeCount="{ row }"><el-tag v-if="row.pendingRechargeCount" type="warning">{{ row.pendingRechargeCount }} 笔</el-tag><span v-else>—</span></template>
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template> <template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
<template #pagination-left><span>共 {{ total }} 个客户账户</span></template> <template #pagination-left><span>共 {{ total }} 个客户账户</span></template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<AdminPage v-else as="div" class="finance-account-detail admin-density" spacing="normal"> <AdminPage v-else as="div" class="finance-account-detail admin-density" spacing="normal">
<section class="surface-panel finance-account-detail__surface"> <section class="surface-panel finance-account-detail__surface">
@@ -101,7 +101,7 @@ describe('FinanceCustomerArapView G2 operations', () => {
}) })
it.each([ it.each([
['DRAFT', ['详情', '编辑', '提交审核', ''], true], ['DRAFT', ['详情', '编辑', '提交审核', '更多'], true],
['APPROVED', ['详情', '调整', '作废'], false], ['APPROVED', ['详情', '调整', '作废'], false],
['PENDING', ['详情', '作废'], false], ['PENDING', ['详情', '作废'], false],
])('keeps fee-source actions reachable at the three-action boundary: %s', async (reviewStatus, labels, overflow) => { ])('keeps fee-source actions reachable at the three-action boundary: %s', async (reviewStatus, labels, overflow) => {
@@ -114,7 +114,7 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(actions.find('.el-dropdown').exists()).toBe(overflow) expect(actions.find('.el-dropdown').exists()).toBe(overflow)
if (overflow) { if (overflow) {
const trigger = actions.get('button[title^="更多费用来源操作"]') const trigger = actions.get('button[title^="更多费用来源操作"]')
expect(trigger.text()).toBe('') expect(trigger.text()).toBe('更多')
await trigger.trigger('click') await trigger.trigger('click')
await flushPromises() await flushPromises()
const item = [...document.querySelectorAll<HTMLElement>('.el-dropdown-menu__item')].find(item => item.textContent?.trim() === '作废') const item = [...document.querySelectorAll<HTMLElement>('.el-dropdown-menu__item')].find(item => item.textContent?.trim() === '作废')
@@ -279,7 +279,7 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(page).toContain('!isUpstreamSource(detail)') expect(page).toContain('!isUpstreamSource(detail)')
expect(page).toContain("['INBOUND_FEE', 'CONTAINER_FEE'].includes(type)") expect(page).toContain("['INBOUND_FEE', 'CONTAINER_FEE'].includes(type)")
expect(page).toContain("=== 'LASTLEG'") expect(page).toContain("=== 'LASTLEG'")
expect(page).toContain('<RowActions v-if="row.feeSourceId" :actions="sourceActions(row as ArapRow)"') expect(page).toContain('<RowActions text-actions v-if="row.feeSourceId" :actions="sourceActions(row as ArapRow)"')
}) })
it('resolves blank detail customer names and keeps source labels business-facing', async () => { it('resolves blank detail customer names and keeps source labels business-facing', async () => {
@@ -400,12 +400,16 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<OperationErrorToast :details="reviewError || mutationError" /> <OperationErrorToast :details="reviewError || mutationError" />
<el-result v-if="!canView" icon="warning" title="无权访问客户应收应付" sub-title="请联系管理员开通账单查看权限" /> <el-result v-if="!canView" icon="warning" title="无权访问客户应收应付" sub-title="请联系管理员开通账单查看权限" />
<ListPageLayout v-else-if="!detailOpen" aria-label="客户应收应付"> <ListPageLayout v-else-if="!detailOpen" aria-label="客户应收应付">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="search" /></template>
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel"> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!customersError && !loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" max-height="calc(100vh - 300px)" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" max-height="calc(100vh - 300px)" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()"> <template #toolbar-start><h1>客户应收应付</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="search" /></template>
<template #notices><template v-if="stateError"><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template></template>
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><el-button v-if="canCreateStatement" type="primary" :disabled="!selectedRows.length" @click="continueSelectedGeneration"><FilePlus2 :size="16" />生成所选对账单</el-button></div></template> <template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><el-button v-if="canCreateStatement" type="primary" :disabled="!selectedRows.length" @click="continueSelectedGeneration"><FilePlus2 :size="16" />生成所选对账单</el-button></div></template>
<template #actions="{ row }"><RowActions v-if="row.feeSourceId" :actions="sourceActions(row as ArapRow)" :more-label="`更多费用来源操作 · ${(row as ArapRow).documentNo || '当前费用'}`" @action="handleSourceAction($event, row as ArapRow)" /><span v-else class="muted-action">手工成本</span></template> <template #actions="{ row }"><RowActions text-actions v-if="row.feeSourceId" :actions="sourceActions(row as ArapRow)" :more-label="`更多费用来源操作 · ${(row as ArapRow).documentNo || '当前费用'}`" @action="handleSourceAction($event, row as ArapRow)" /><span v-else class="muted-action">手工成本</span></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template> <template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template> <template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template> <template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
@@ -133,7 +133,7 @@ describe('FinanceCustomerStatementsView', () => {
expect(source).toContain("{ key: 'approve', label: '通过', priority: 250") expect(source).toContain("{ key: 'approve', label: '通过', priority: 250")
expect(source).toContain("{ key: 'reject', label: '驳回', priority: 200") expect(source).toContain("{ key: 'reject', label: '驳回', priority: 200")
expect(source).toContain("{ key: 'void', label: '作废', tone: 'danger', divided: true") expect(source).toContain("{ key: 'void', label: '作废', tone: 'danger', divided: true")
expect(source).toContain('<RowActions :actions="statementActions(row as FinanceStatementReadModel)"') expect(source).toContain('<RowActions text-actions :actions="statementActions(row as FinanceStatementReadModel)"')
}) })
it('keeps three available row actions visible with primary and danger semantics', async () => { it('keeps three available row actions visible with primary and danger semantics', async () => {
@@ -669,16 +669,19 @@ watch(detailRouteId, (statementId) => {
</el-tabs> </el-tabs>
</DetailPageLayout> </DetailPageLayout>
<ListPageLayout v-else aria-label="客户对账单"> <ListPageLayout v-else aria-label="客户对账单">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" :loading="loading" 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><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
<section class="surface-panel table-panel"> <template #toolbar-start><h1>客户对账单</h1></template>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail"> <template #filters><QueryToolbar v-model="filters" ariaLabel="客户对账单筛选" :fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></template>
<template #notices><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template>
<template #toolbar-actions> <template #toolbar-actions>
<el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportStatements"><Download :size="15" />导出</el-button> <el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportStatements"><Download :size="15" />导出</el-button>
<el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button> <el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button>
</template> </template>
<template #actions="{ row }"><RowActions :actions="statementActions(row as FinanceStatementReadModel)" :more-label="`更多对账单操作 · ${(row as FinanceStatementReadModel).billNo}`" @action="handleStatementAction($event, row as FinanceStatementReadModel)" /></template> <template #actions="{ row }"><RowActions text-actions :actions="statementActions(row as FinanceStatementReadModel)" :more-label="`更多对账单操作 · ${(row as FinanceStatementReadModel).billNo}`" @action="handleStatementAction($event, row as FinanceStatementReadModel)" /></template>
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template> <template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #customerConfirmationStatus="{ row }"><el-tag :type="financeStatusTone(row.customerConfirmationStatus || 'NOT_REQUIRED')">{{ financeStatusLabel(row.customerConfirmationStatus || 'NOT_REQUIRED') }}</el-tag></template> <template #customerConfirmationStatus="{ row }"><el-tag :type="financeStatusTone(row.customerConfirmationStatus || 'NOT_REQUIRED')">{{ financeStatusLabel(row.customerConfirmationStatus || 'NOT_REQUIRED') }}</el-tag></template>
<template #customerName="{ row }">{{ customerDisplayName(row.customerId, row.customerName) }}</template> <template #customerName="{ row }">{{ customerDisplayName(row.customerId, row.customerName) }}</template>
@@ -694,10 +697,7 @@ watch(detailRouteId, (statementId) => {
<template #updatedByName="{ row }">{{ operatorDisplayName(row.updatedByName) }}</template> <template #updatedByName="{ row }">{{ operatorDisplayName(row.updatedByName) }}</template>
<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template> <template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template> <template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
</DataTable> </DataTable></section>
</section>
</template>
</ListPageLayout> </ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement"> <AppDialog class="admin-density-dialog" v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement">
@@ -6,8 +6,10 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/Fi
const statementsSource = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8') const statementsSource = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
describe('Finance formal settlement desktop presentation', () => { describe('Finance formal settlement desktop presentation', () => {
it('keeps settlement totals on the left and list actions on the right of the table toolbar', () => { it('keeps the title and actions in the toolbar and settlement totals before results', () => {
expect(source).toContain('<template #toolbar-start>') expect(source).toContain('<template #toolbar-start>')
expect(source).toContain('<h1>{{ title }}</h1>')
expect(source).toContain('<template #before-results>')
expect(source).toContain('<dl class="density-detail-overview settlement-summary" aria-label="结算汇总">') expect(source).toContain('<dl class="density-detail-overview settlement-summary" aria-label="结算汇总">')
expect(source).toContain('<template #toolbar-actions>') expect(source).toContain('<template #toolbar-actions>')
expect(source).toContain('<div class="settlement-list-actions">') expect(source).toContain('<div class="settlement-list-actions">')
@@ -26,9 +28,9 @@ describe('Finance formal settlement desktop presentation', () => {
} }
}) })
it('renders at most three permitted row actions as shared outlined text buttons', () => { it('renders at most three permitted row actions as shared text buttons', () => {
expect(source.match(/key: 'actions', label: '操作', width: 250/g)).toHaveLength(2) expect(source.match(/key: 'actions', label: '操作', width: 250/g)).toHaveLength(2)
expect(source).toContain('class="settlement-row-actions density-actions"') expect(source).toContain('class="settlement-row-actions density-actions row-actions admin-text-actions"')
for (const action of ['openDetail(row)', 'openReceipt(row)', 'openPayment(row)']) expect(source).toContain('@click="' + action + '"') for (const action of ['openDetail(row)', 'openReceipt(row)', 'openPayment(row)']) expect(source).toContain('@click="' + action + '"')
expect(source).toContain('data-tone="danger"') expect(source).toContain('data-tone="danger"')
expect(source).toContain('class="density-action"') expect(source).toContain('class="density-action"')
@@ -589,37 +589,38 @@ onMounted(() => {
</section> </section>
</DetailPageLayout> </DetailPageLayout>
<ListPageLayout v-else :aria-label="title"> <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-on-clear="false" @search="load" />
<section><DataTable :preference-key="`finance-settlement-${kind}-table`" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
<template #toolbar-start><h1>{{ title }}</h1></template>
<template #filters>
<QueryToolbar v-model="filters" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" :search-on-clear="false" @search="load" />
</template> </template>
<template v-if="customerError || error" #state> <template #notices><template v-if="customerError || error">
<LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState> <LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState>
</template> </template></template>
<template v-if="!customerError && !error"> <template #before-results>
<section class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
<template #toolbar-start>
<dl class="density-detail-overview settlement-summary" aria-label="结算汇总"> <dl class="density-detail-overview settlement-summary" aria-label="结算汇总">
<div class="summary-item"><dt>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</dt><dd>{{ money(totals.amount) }}</dd></div> <div class="summary-item"><dt>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</dt><dd>{{ money(totals.amount) }}</dd></div>
<div class="summary-item"><dt>{{ kind === 'receivable' ? '已收' : '已付' }}</dt><dd class="is-success">{{ money(totals.paid) }}</dd></div> <div class="summary-item"><dt>{{ kind === 'receivable' ? '已收' : '已付' }}</dt><dd class="is-success">{{ money(totals.paid) }}</dd></div>
<div class="summary-item"><dt>{{ kind === 'receivable' ? '待收' : '待付' }}</dt><dd class="is-danger">{{ money(totals.outstanding) }}</dd></div> <div class="summary-item"><dt>{{ kind === 'receivable' ? '待收' : '待付' }}</dt><dd class="is-danger">{{ money(totals.outstanding) }}</dd></div>
</dl> </dl>
</template> </template>
<template #toolbar-actions> <template #toolbar-actions>
<div class="settlement-list-actions"> <div class="settlement-list-actions">
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="15" />{{ createLabel }}</el-button> <el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="15" />{{ createLabel }}</el-button>
<el-button v-if="canExport" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportRows"><Download :size="15" />导出</el-button> <el-button v-if="canExport" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportRows"><Download :size="15" />导出</el-button>
</div> </div>
</template> </template>
<template #actions="{ row }"> <template #actions="{ row }"><div class="settlement-row-actions density-actions row-actions admin-text-actions">
<div class="settlement-row-actions density-actions">
<el-button data-tone="neutral" class="density-action" size="small" @click="openDetail(row)">详情</el-button> <el-button data-tone="neutral" class="density-action" size="small" @click="openDetail(row)">详情</el-button>
<el-button data-tone="neutral" class="density-action" v-if="canReceive && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" size="small" :loading="receiptBusy" @click="openReceipt(row)">收款</el-button> <el-button data-tone="neutral" class="density-action" v-if="canReceive && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" size="small" :loading="receiptBusy" @click="openReceipt(row)">收款</el-button>
<el-button data-tone="neutral" class="density-action" v-if="canPay && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" size="small" @click="openPayment(row)">付款</el-button> <el-button data-tone="neutral" class="density-action" v-if="canPay && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" size="small" @click="openPayment(row)">付款</el-button>
<el-button data-tone="danger" class="density-action" v-if="canVoid && row.status === 'ISSUED'" size="small" :loading="actionBusy" @click="performAction(row as SettlementRow, 'VOID')">作废</el-button> <el-button data-tone="danger" class="density-action" v-if="canVoid && row.status === 'ISSUED'" size="small" :loading="actionBusy" @click="performAction(row as SettlementRow, 'VOID')">作废</el-button>
<el-button data-tone="neutral" class="density-action" v-if="canVoid && row.status === 'VOIDED'" size="small" :loading="actionBusy" @click="performAction(row as SettlementRow, 'REOPEN')">重开</el-button> <el-button data-tone="neutral" class="density-action" v-if="canVoid && row.status === 'VOIDED'" size="small" :loading="actionBusy" @click="performAction(row as SettlementRow, 'REOPEN')">重开</el-button>
</div> </div></template>
</template>
<template #customer="{ row }">{{ customerName(String(row.customerId), row.customerName) }}</template> <template #customer="{ row }">{{ customerName(String(row.customerId), row.customerName) }}</template>
<template #supplier="{ row }">{{ row.supplierName || '—' }}</template> <template #supplier="{ row }">{{ row.supplierName || '—' }}</template>
<template #feeItemSummary="{ row }">{{ row.feeItemSummary || '—' }}</template> <template #feeItemSummary="{ row }">{{ row.feeItemSummary || '—' }}</template>
@@ -636,9 +637,7 @@ onMounted(() => {
<template #lastPaymentAt="{ row }">{{ displayDateTime(row.lastPaymentAt) }}</template> <template #lastPaymentAt="{ row }">{{ displayDateTime(row.lastPaymentAt) }}</template>
<template #settlementStatus="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template> <template #settlementStatus="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template> <template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
</DataTable> </DataTable></section>
</section>
</template>
</ListPageLayout> </ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate"> <AppDialog class="admin-density-dialog" v-model="createOpen" :title="`生成${title}`" width="min(600px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate">
@@ -92,27 +92,24 @@ onMounted(() => { void loadCustomers(); void load() })
<AdminPage class="receivable-statistics-page admin-density" spacing="normal"> <AdminPage class="receivable-statistics-page admin-density" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" /> <el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" />
<template v-else> <template v-else>
<section class="surface-panel statistics-query" aria-label="应收款统计筛选"> <DataTable class="statistics-table" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>应收款统计表</h1></template>
<template #filters><section class="query-toolbar statistics-query" aria-label="应收款统计筛选">
<div class="query-field"><span>统计月份</span><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div> <div class="query-field"><span>统计月份</span><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
<div class="query-field"><span>应收单位</span><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div> <div class="query-field"><span>应收单位</span><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div>
<div class="query-actions"><el-button type="primary" :loading="loading" @click="search"><Search :size="16" />查询</el-button><el-button @click="reset"><RotateCcw :size="16" />重置</el-button></div> <div class="query-actions"><el-button type="primary" :loading="loading" @click="search"><Search :size="16" />查询</el-button><el-button @click="reset"><RotateCcw :size="16" />重置</el-button></div>
</section> </section></template>
<LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState> <template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template v-else> <template #before-results><dl class="density-detail-overview statistics-summary" aria-label="CNY应收汇总">
<dl class="density-detail-overview statistics-summary" aria-label="CNY应收汇总">
<div><dt>应收总额</dt><dd>CNY {{ money(result?.totalReceivable) }}</dd></div> <div><dt>应收总额</dt><dd>CNY {{ money(result?.totalReceivable) }}</dd></div>
<div><dt>已收合计</dt><dd class="is-received">CNY {{ money(result?.totalReceived) }}</dd></div> <div><dt>已收合计</dt><dd class="is-received">CNY {{ money(result?.totalReceived) }}</dd></div>
<div><dt>余额未收</dt><dd class="is-outstanding">CNY {{ money(result?.totalOutstanding) }}</dd></div> <div><dt>余额未收</dt><dd class="is-outstanding">CNY {{ money(result?.totalOutstanding) }}</dd></div>
</dl> </dl><div class="statistics-report-heading"><h2>应收款明细</h2><span>共 {{ result?.totalCustomers ?? 0 }} 个应收单位 · 仅统计CNY正式应收结算单</span></div></template>
<section class="surface-panel statistics-table">
<header><h2>应收款明细</h2><span>共 {{ result?.totalCustomers ?? 0 }} 个应收单位 · 仅统计CNY正式应收结算单</span></header>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template> <template #toolbar-actions><div class="report-actions"><el-button :disabled="loading || !result" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template>
<template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template> <template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template>
<template #pagination-left>统计月份:{{ result?.statisticsMonth }} · 币种:CNY</template> <template #pagination-left>统计月份:{{ result?.statisticsMonth }} · 币种:CNY</template>
</DataTable> </DataTable>
</section>
</template>
</template> </template>
</AdminPage> </AdminPage>
</template> </template>
@@ -126,9 +123,9 @@ onMounted(() => { void loadCustomers(); void load() })
.query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; } .query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; }
.outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; } .outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
.statistics-table { overflow: hidden; } .statistics-table { overflow: hidden; }
.statistics-table > header { display: flex; align-items: center; justify-content: space-between; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--cc-border, #e3e8ef); } .statistics-report-heading { display: flex; align-items: center; justify-content: space-between; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--cc-border, #e3e8ef); }
.statistics-table h2 { margin: 0; font-size: var(--admin-font-section); } .statistics-table h2 { margin: 0; font-size: var(--admin-font-section); }
.statistics-table header span { color: var(--cc-muted); font-size: var(--admin-font-body); } .statistics-report-heading span { color: var(--cc-muted); font-size: var(--admin-font-body); }
@media print { @media print {
.statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; } .statistics-query, :deep(.data-table__toolbar), :deep(.data-table__pagination) { display: none !important; }
.receivable-statistics-page { gap: 10px; color: #000; } .receivable-statistics-page { gap: 10px; color: #000; }
+6 -6
View File
@@ -1,15 +1,15 @@
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
export const financeRoutes = [ export const financeRoutes = [
{ path: 'finance/customer-accounts', name: 'finance-customer-accounts', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户', description: '查看客户 CNY 应收台账并维护独立账期', permission: 'platform:finance:customer-account:list' } }, { path: 'finance/customer-accounts', name: 'finance-customer-accounts', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { showPageHeading: false, title: '客户账户', description: '查看客户 CNY 应收台账并维护独立账期', permission: 'platform:finance:customer-account:list' } },
{ path: 'finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户详情', description: '查看客户派生财务汇总与账户流水', permission: 'platform:finance:customer-account:list', showPageHeading: false } }, { path: 'finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户详情', description: '查看客户派生财务汇总与账户流水', permission: 'platform:finance:customer-account:list', showPageHeading: false } },
{ path: 'finance/customer-arap', name: 'finance-customer-arap', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '客户应收应付', description: '按客户追溯入库单、订单和柜次费用来源', permission: 'platform:finance:customer-arap:list' } }, { path: 'finance/customer-arap', name: 'finance-customer-arap', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { showPageHeading: false, title: '客户应收应付', description: '按客户追溯入库单、订单和柜次费用来源', permission: 'platform:finance:customer-arap:list' } },
{ path: 'finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '费用来源详情', description: '查看费用来源、审核进度、关联业务与操作记录', permission: 'platform:finance:customer-arap:list', showPageHeading: false } }, { path: 'finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '费用来源详情', description: '查看费用来源、审核进度、关联业务与操作记录', permission: 'platform:finance:customer-arap:list', showPageHeading: false } },
{ path: 'finance/receivable-statistics', name: 'finance-receivable-statistics', component: () => import('./pages/FinanceReceivableStatisticsView.vue'), meta: { title: '应收款统计表', description: '按月份和客户统计 CNY 应收、已收与未收余额', permission: 'platform:finance:receivable-statistics:list' } }, { path: 'finance/receivable-statistics', name: 'finance-receivable-statistics', component: () => import('./pages/FinanceReceivableStatisticsView.vue'), meta: { showPageHeading: false, title: '应收款统计表', description: '按月份和客户统计 CNY 应收、已收与未收余额', permission: 'platform:finance:receivable-statistics:list' } },
{ path: 'finance/customer-statements', name: 'finance-customer-statements', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单', description: '维护 CNY 客户对账单并在出账后冻结', permission: 'platform:finance:customer-statement:list' } }, { path: 'finance/customer-statements', name: 'finance-customer-statements', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { showPageHeading: false, title: '客户对账单', description: '维护 CNY 客户对账单并在出账后冻结', permission: 'platform:finance:customer-statement:list' } },
{ path: 'finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单详情', description: '查看对账单概要、费用明细、支付历史与调整记录', permission: 'platform:finance:customer-statement:list', showPageHeading: false } }, { path: 'finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单详情', description: '查看对账单概要、费用明细、支付历史与调整记录', permission: 'platform:finance:customer-statement:list', showPageHeading: false } },
{ path: 'finance/settlement-receivables', name: 'finance-settlement-receivables', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '结算单应收', description: '登记客户收款、凭证及对应对账单金额', permission: 'platform:finance:settlement-ar:list' } }, { path: 'finance/settlement-receivables', name: 'finance-settlement-receivables', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { showPageHeading: false, title: '结算单应收', description: '登记客户收款、凭证及对应对账单金额', permission: 'platform:finance:settlement-ar:list' } },
{ path: 'finance/settlement-receivables/:settlementId', name: 'finance-settlement-receivable-detail', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '应收结算单详情', description: '查看应收结算信息、费用明细和收款记录', permission: 'platform:finance:settlement-ar:list', showPageHeading: false } }, { path: 'finance/settlement-receivables/:settlementId', name: 'finance-settlement-receivable-detail', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '应收结算单详情', description: '查看应收结算信息、费用明细和收款记录', permission: 'platform:finance:settlement-ar:list', showPageHeading: false } },
{ path: 'finance/settlement-payables', name: 'finance-settlement-payables', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '结算单应付', description: '登记单票及整柜公共成本并追溯业务来源', permission: 'platform:finance:settlement-ap:list' } }, { path: 'finance/settlement-payables', name: 'finance-settlement-payables', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { showPageHeading: false, title: '结算单应付', description: '登记单票及整柜公共成本并追溯业务来源', permission: 'platform:finance:settlement-ap:list' } },
{ path: 'finance/settlement-payables/:settlementId', name: 'finance-settlement-payable-detail', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '应付结算单详情', description: '查看应付结算信息、费用明细和付款记录', permission: 'platform:finance:settlement-ap:list', showPageHeading: false } }, { path: 'finance/settlement-payables/:settlementId', name: 'finance-settlement-payable-detail', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '应付结算单详情', description: '查看应付结算信息、费用明细和付款记录', permission: 'platform:finance:settlement-ap:list', showPageHeading: false } },
] satisfies RouteRecordRaw[] ] satisfies RouteRecordRaw[]
+13 -13
View File
@@ -382,28 +382,30 @@ onMounted(loadInitialData)
<template> <template>
<AdminPage class="admin-density iam-density" as="div"> <AdminPage class="admin-density iam-density" as="div">
<ListPageLayout aria-label="内部用户管理"> <ListPageLayout aria-label="内部用户管理">
<template #toolbar>
<section :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="users" :columns="userColumns" preference-key="internal-users-table"
:loading="loading" :total="total" aria-label="内部用户列表" row-key="membershipId" empty-text="没有符合筛选条件的内部用户">
<template #toolbar-start><h1>用户管理</h1></template>
<template #filters>
<QueryToolbar <QueryToolbar
v-model="filters" v-model="filters"
class="surface-panel"
ariaLabel="用户筛选" ariaLabel="用户筛选"
:fields="queryFields" :fields="queryFields"
preference-key="internal-users-query" preference-key="internal-users-query"
@search="applyFilters" @search="applyFilters"
/> />
</template> </template>
<template v-if="loadError" #state> <template #notices><template v-if="loadError">
<LoadErrorState title="内部用户列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadInitialData"> <LoadErrorState title="内部用户列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadInitialData">
<template #details><ApiErrorMeta :details="loadError" /></template> <template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState> </LoadErrorState>
</template> </template></template>
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="users" :columns="userColumns" preference-key="internal-users-table"
:loading="loading" :total="total" aria-label="内部用户列表" row-key="membershipId" empty-text="没有符合筛选条件的内部用户">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></template> <template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></template>
<template #actions="{ row }"> <template #actions="{ row }"><div class="table-actions density-actions row-actions admin-text-actions">
<div class="table-actions density-actions">
<el-button class="density-action" v-if="canEdit || canChangeStatus || canResetPassword" size="small" data-tone="neutral" @click="openEdit(row as PlatformManagedUser)"> <el-button class="density-action" v-if="canEdit || canChangeStatus || canResetPassword" size="small" data-tone="neutral" @click="openEdit(row as PlatformManagedUser)">
编辑 编辑
</el-button> </el-button>
@@ -414,8 +416,7 @@ onMounted(loadInitialData)
@click="toggleStatus(row as PlatformManagedUser)"> @click="toggleStatus(row as PlatformManagedUser)">
{{ (row as PlatformManagedUser).status === 'ACTIVE' ? '禁用' : '启用' }} {{ (row as PlatformManagedUser).status === 'ACTIVE' ? '禁用' : '启用' }}
</el-button> </el-button>
</div> </div></template>
</template>
<template #roles="{ row }"> <template #roles="{ row }">
<div class="tag-list"> <div class="tag-list">
<el-tag v-for="role in (row as PlatformManagedUser).roleNames" :key="role" effect="plain" size="small">{{ role }}</el-tag> <el-tag v-for="role in (row as PlatformManagedUser).roleNames" :key="role" effect="plain" size="small">{{ role }}</el-tag>
@@ -425,8 +426,7 @@ onMounted(loadInitialData)
<template #status="{ row }"><StatusBadge :status="(row as PlatformManagedUser).status === 'ACTIVE' ? '正常' : '禁用'" /></template> <template #status="{ row }"><StatusBadge :status="(row as PlatformManagedUser).status === 'ACTIVE' ? '正常' : '禁用'" /></template>
<template #lastLoginAt="{ row }">{{ formatLogin((row as PlatformManagedUser).lastLoginAt) }}</template> <template #lastLoginAt="{ row }">{{ formatLogin((row as PlatformManagedUser).lastLoginAt) }}</template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template> <template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density" destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser"> <AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density" destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
@@ -58,7 +58,7 @@ function mountServerView(permissionCodes = ['platform:permission:list', 'platfor
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<slot name="details" /></div>' }, LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<slot name="details" /></div>' },
DataTable: { DataTable: {
props: ['data'], props: ['data'],
template: '<section><slot name="toolbar-actions" /><div v-for="row in data" :key="row.id" class="row-actions" :data-row-id="row.id"><slot name="actions" :row="row" /></div><span data-testid="row-count">{{ data.length }}</span></section>', template: '<section><slot name="toolbar-start" /><slot name="toolbar-actions" /><slot name="filters" /><slot name="notices" /><slot name="before-results" /><div v-for="row in data" :key="row.id" class="row-actions" :data-row-id="row.id"><slot name="actions" :row="row" /></div><span data-testid="row-count">{{ data.length }}</span></section>',
}, },
'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' }, 'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' },
'el-input': true, 'el-input': true,
@@ -91,7 +91,7 @@ describe('PermissionsView IAM mode', () => {
expect(permissionsSource).toContain("{ key: 'edit', label: '编辑', priority: 300") expect(permissionsSource).toContain("{ key: 'edit', label: '编辑', priority: 300")
expect(permissionsSource).toContain("{ key: 'status', label: row.status === '启用' ? '停用' : '启用'") expect(permissionsSource).toContain("{ key: 'status', label: row.status === '启用' ? '停用' : '启用'")
expect(permissionsSource).toContain("{ key: 'delete', label: '删除', tone: 'danger', divided: true") expect(permissionsSource).toContain("{ key: 'delete', label: '删除', tone: 'danger', divided: true")
expect(permissionsSource).toContain('<RowActions v-if="permissionActions(row as Permission).some') expect(permissionsSource).toContain('<RowActions text-actions v-if="permissionActions(row as Permission).some')
expect(permissionsSource).toContain('<el-button link type="primary" @click="expandAllRows">展开全部</el-button>') expect(permissionsSource).toContain('<el-button link type="primary" @click="expandAllRows">展开全部</el-button>')
expect(permissionsSource).not.toContain('canAddChild(row as Permission)" link') expect(permissionsSource).not.toContain('canAddChild(row as Permission)" link')
expect(permissionsSource).not.toContain('canEditRow(row as Permission)" link') expect(permissionsSource).not.toContain('canEditRow(row as Permission)" link')
+12 -9
View File
@@ -275,25 +275,28 @@ onMounted(loadPermissions)
<template> <template>
<AdminPage class="admin-density iam-density" as="div"> <AdminPage class="admin-density iam-density" as="div">
<ListPageLayout aria-label="平台权限资源管理"> <ListPageLayout aria-label="平台权限资源管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="权限筛选" :fields="queryFields" preference-key="permissions-query" @search="applyFilters" /></template>
<template v-if="loadError" #state>
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" aria-label="平台权限资源列表" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
<template #toolbar-start><h1>菜单权限</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="权限筛选" :fields="queryFields" preference-key="permissions-query" @search="applyFilters" /></template>
<template #notices><template v-if="loadError">
<LoadErrorState title="权限资源暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadPermissions"> <LoadErrorState title="权限资源暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadPermissions">
<template #details><ApiErrorMeta :details="loadError" /></template> <template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState> </LoadErrorState>
</template> </template></template>
<template #before-results><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span><span class="permission-protection-hint">受保护资源不可编辑、停用或删除</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div></template>
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" aria-label="平台权限资源列表" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
<template #toolbar-start><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span><span class="permission-protection-hint">受保护资源不可编辑、停用或删除</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template> <template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template>
<template #actions="{ row }"><RowActions v-if="permissionActions(row as Permission).some(action => action.visible !== false)" :actions="permissionActions(row as Permission)" :more-label="moreActionsLabel(row as Permission)" @action="handlePermissionAction($event, row as Permission)" /><span v-else class="secondary-text">仅查看</span></template> <template #actions="{ row }"><RowActions text-actions v-if="permissionActions(row as Permission).some(action => action.visible !== false)" :actions="permissionActions(row as Permission)" :more-label="moreActionsLabel(row as Permission)" @action="handlePermissionAction($event, row as Permission)" /><span v-else class="secondary-text">仅查看</span></template>
<template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="['目录', '菜单', '页面'].includes((row as Permission).type)" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template> <template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="['目录', '菜单', '页面'].includes((row as Permission).type)" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template>
<template #code="{ row }"><code class="code-value">{{ (row as Permission).code }}</code></template> <template #code="{ row }"><code class="code-value">{{ (row as Permission).code }}</code></template>
<template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template> <template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template>
<template #status="{ row }"><StatusBadge :status="(row as Permission).status || '—'" /></template> <template #status="{ row }"><StatusBadge :status="(row as Permission).status || '—'" /></template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 项</span></template> <template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 项</span></template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<AppDialog v-model="editorOpen" :title="form.id ? '编辑权限资源' : '新增权限资源'" width="min(720px, calc(100vw - 28px))" class="permission-editor-dialog admin-density-dialog iam-density" :confirm-loading="saving" :confirm-text="form.id ? '保存修改' : '保存权限'" :show-confirm="form.id ? canEdit : canCreate" @confirm="savePermission"> <AppDialog v-model="editorOpen" :title="form.id ? '编辑权限资源' : '新增权限资源'" width="min(720px, calc(100vw - 28px))" class="permission-editor-dialog admin-density-dialog iam-density" :confirm-loading="saving" :confirm-text="form.id ? '保存修改' : '保存权限'" :show-confirm="form.id ? canEdit : canCreate" @confirm="savePermission">
+2 -2
View File
@@ -45,7 +45,7 @@ function mountView(pinia = createPinia()) {
FormSection: { template: '<section><slot /></section>' }, FormSection: { template: '<section><slot /></section>' },
AppDialog: { template: '<section><slot /><slot name="footer" /></section>' }, AppDialog: { template: '<section><slot /><slot name="footer" /></section>' },
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<slot name="details" /></div>' }, LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<slot name="details" /></div>' },
DataTable: { props: ['data'], template: '<section><slot name="toolbar-actions" /><span data-testid="row-count">{{ data.length }}</span></section>' }, DataTable: { props: ['data'], template: '<section><slot name="toolbar-start" /><slot name="toolbar-actions" /><slot name="filters" /><slot name="notices" /><span data-testid="row-count">{{ data.length }}</span></section>' },
'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' }, 'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' },
'el-input': true, 'el-input': true,
'el-option': true, 'el-option': true,
@@ -76,7 +76,7 @@ describe('RolesView', () => {
expect(rolesSource).toContain("(row as Role).status || '—'") expect(rolesSource).toContain("(row as Role).status || '—'")
}) })
it('uses bordered striped tables and semantic role operation buttons', () => { it('uses bordered striped tables and semantic role operation buttons', () => {
expect(rolesSource).toContain('border stripe') expect(rolesSource).toContain('appearance="integrated" :stripe="false"')
expect(rolesSource).toContain('size="small" data-tone="neutral"') expect(rolesSource).toContain('size="small" data-tone="neutral"')
expect(rolesSource).toContain('size="small" :data-tone=') expect(rolesSource).toContain('size="small" :data-tone=')
expect(rolesSource).toContain('size="small" data-tone="danger"') expect(rolesSource).toContain('size="small" data-tone="danger"')
+11 -8
View File
@@ -238,24 +238,27 @@ onMounted(loadRoles)
<template> <template>
<AdminPage class="admin-density iam-density" as="div"> <AdminPage class="admin-density iam-density" as="div">
<ListPageLayout aria-label="平台角色管理"> <ListPageLayout aria-label="平台角色管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="角色筛选" :fields="queryFields" preference-key="roles-query" @search="applyFilters" /></template>
<template v-if="loadError" #state>
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" border aria-label="平台角色列表" empty-text="没有符合条件的角色">
<template #toolbar-start><h1>角色权限</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="角色筛选" :fields="queryFields" preference-key="roles-query" @search="applyFilters" /></template>
<template #notices><template v-if="loadError">
<LoadErrorState title="角色列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadRoles"> <LoadErrorState title="角色列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadRoles">
<template #details><ApiErrorMeta :details="loadError" /></template> <template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState> </LoadErrorState>
</template> </template></template>
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" border stripe aria-label="平台角色列表" empty-text="没有符合条件的角色">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></template> <template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></template>
<template #actions="{ row }"><div v-if="canEdit || canGrant || access.can('platform:role:status') || access.can('platform:role:delete')" class="table-actions density-actions"><el-button class="density-action" v-if="canEdit || canGrant" size="small" data-tone="neutral" :disabled="rowActionId === (row as Role).id || (row as Role).builtIn" @click="openEdit(row as Role)">配置</el-button><el-button class="density-action" v-if="access.can('platform:role:status')" size="small" :data-tone="(row as Role).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Role).id" :disabled="(row as Role).builtIn" @click="toggleStatus(row as Role)">{{ (row as Role).status === '启用' ? '停用' : '启用' }}</el-button><el-button class="density-action" v-if="access.can('platform:role:delete')" size="small" data-tone="danger" :disabled="(row as Role).builtIn || (row as Role).userCount > 0 || rowActionId === (row as Role).id" @click="removeRole(row as Role)">删除</el-button></div><span v-else class="secondary-text">仅查看</span></template> <template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><div v-if="canEdit || canGrant || access.can('platform:role:status') || access.can('platform:role:delete')" class="table-actions density-actions"><el-button class="density-action" v-if="canEdit || canGrant" size="small" data-tone="neutral" :disabled="rowActionId === (row as Role).id || (row as Role).builtIn" @click="openEdit(row as Role)">配置</el-button><el-button class="density-action" v-if="access.can('platform:role:status')" size="small" :data-tone="(row as Role).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Role).id" :disabled="(row as Role).builtIn" @click="toggleStatus(row as Role)">{{ (row as Role).status === '启用' ? '停用' : '启用' }}</el-button><el-button class="density-action" v-if="access.can('platform:role:delete')" size="small" data-tone="danger" :disabled="(row as Role).builtIn || (row as Role).userCount > 0 || rowActionId === (row as Role).id" @click="removeRole(row as Role)">删除</el-button></div><span v-else class="secondary-text">仅查看</span></div></template>
<template #role="{ row }"><div class="role-cell"><span><ShieldCheck :size="18" /></span><div><strong>{{ (row as Role).name }}</strong></div></div></template> <template #role="{ row }"><div class="role-cell"><span><ShieldCheck :size="18" /></span><div><strong>{{ (row as Role).name }}</strong></div></div></template>
<template #permissionSummary="{ row }"><div class="primary-cell"><strong>{{ permissionSummary(row as Role) }}</strong><span>平台 IAM 权限</span></div></template> <template #permissionSummary="{ row }"><div class="primary-cell"><strong>{{ permissionSummary(row as Role) }}</strong><span>平台 IAM 权限</span></div></template>
<template #roleType="{ row }"><el-tag :type="(row as Role).builtIn ? 'warning' : 'info'" effect="plain" size="small">{{ (row as Role).builtIn ? '系统内置' : '自定义' }}</el-tag></template> <template #roleType="{ row }"><el-tag :type="(row as Role).builtIn ? 'warning' : 'info'" effect="plain" size="small">{{ (row as Role).builtIn ? '系统内置' : '自定义' }}</el-tag></template>
<template #status="{ row }"><StatusBadge :status="(row as Role).status || '—'" /></template> <template #status="{ row }"><StatusBadge :status="(row as Role).status || '—'" /></template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template> <template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<AppDialog v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density" :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole"> <AppDialog v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density" :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole">
+3 -3
View File
@@ -5,18 +5,18 @@ export const iamRoutes = [
path: 'system/users', path: 'system/users',
name: 'users', name: 'users',
component: () => import('@/modules/iam/pages/InternalUsersView.vue'), component: () => import('@/modules/iam/pages/InternalUsersView.vue'),
meta: { title: '用户管理', description: '统一管理平台内部用户、状态及角色', permission: 'platform:user:list' }, meta: { showPageHeading: false, title: '用户管理', description: '统一管理平台内部用户、状态及角色', permission: 'platform:user:list' },
}, },
{ {
path: 'system/roles', path: 'system/roles',
name: 'roles', name: 'roles',
component: () => import('@/modules/iam/pages/RolesView.vue'), component: () => import('@/modules/iam/pages/RolesView.vue'),
meta: { title: '角色权限', description: '维护平台角色并配置菜单及操作权限', permission: 'platform:role:list' }, meta: { showPageHeading: false, title: '角色权限', description: '维护平台角色并配置菜单及操作权限', permission: 'platform:role:list' },
}, },
{ {
path: 'system/permissions', path: 'system/permissions',
name: 'permissions', name: 'permissions',
component: () => import('@/modules/iam/pages/PermissionsView.vue'), component: () => import('@/modules/iam/pages/PermissionsView.vue'),
meta: { title: '菜单权限', description: '维护菜单、页面、按钮和接口权限编码', permission: 'platform:permission:list' }, meta: { showPageHeading: false, title: '菜单权限', description: '维护菜单、页面、按钮和接口权限编码', permission: 'platform:permission:list' },
}, },
] satisfies RouteRecordRaw[] ] satisfies RouteRecordRaw[]
@@ -338,32 +338,11 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
<template> <template>
<AdminPage as="div" class="inbound-task-page admin-density"> <AdminPage as="div" class="inbound-task-page admin-density">
<ListPageLayout :aria-label="`${taskLabel}待办`"> <ListPageLayout :aria-label="`${taskLabel}待办`">
<template #toolbar>
<QueryToolbar
v-model="filters"
class="surface-panel"
:ariaLabel="`${taskLabel}待办筛选`"
:fields="queryFields"
:preference-key="`${preferenceKey}-query`"
:search-on-reset="false"
@search="applyFilters"
@reset="resetFilters"
/>
</template>
<template v-if="customerLoadError || loadError" #state>
<div class="state-stack">
<el-alert v-if="customerLoadError" type="warning" :closable="false" show-icon>
<template #title><span>{{ customerLoadError.message }}</span></template>
</el-alert>
<LoadErrorState v-if="loadError" :title="`${taskLabel}待办暂时无法加载`" :message="loadError.message" @retry="loadTasks">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</div>
</template>
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
<DataTable
<section :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page" v-model:page="page"
v-model:page-size="pageSize" v-model:page-size="pageSize"
:data="result.records" :data="result.records"
@@ -377,12 +356,35 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
:aria-label="`${taskLabel}待办列表`" :aria-label="`${taskLabel}待办列表`"
@selection-change="handleReviewSelectionChange" @selection-change="handleReviewSelectionChange"
> >
<template #toolbar-start><h1>{{ isReview ? '测量数据审核' : '订单测量' }}</h1></template>
<template #filters>
<QueryToolbar
v-model="filters"
:ariaLabel="`${taskLabel}待办筛选`"
:fields="queryFields"
:preference-key="`${preferenceKey}-query`"
:search-on-reset="false"
@search="applyFilters"
@reset="resetFilters"
/>
</template>
<template #notices><template v-if="customerLoadError || loadError">
<div class="state-stack">
<el-alert v-if="customerLoadError" type="warning" :closable="false" show-icon>
<template #title><span>{{ customerLoadError.message }}</span></template>
</el-alert>
<LoadErrorState v-if="loadError" :title="`${taskLabel}待办暂时无法加载`" :message="loadError.message" @retry="loadTasks">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</div>
</template></template>
<template v-if="isReview && access.can(actionPermission)" #toolbar-actions> <template v-if="isReview && access.can(actionPermission)" #toolbar-actions>
<el-button type="success" :loading="batchReviewLoading" :disabled="loading || !selectedReviewRows.length" @click="batchApproveMeasurements"><CircleCheckBig :size="16" />批量审核通过<span v-if="selectedReviewRows.length">({{ selectedReviewRows.length }})</span></el-button> <el-button type="success" :loading="batchReviewLoading" :disabled="loading || !selectedReviewRows.length" @click="batchApproveMeasurements"><CircleCheckBig :size="16" />批量审核通过<span v-if="selectedReviewRows.length">({{ selectedReviewRows.length }})</span></el-button>
</template> </template>
<template #pagination-left>当前显示 {{ result.records.length }} 条 · 共 {{ result.total }} 条</template> <template #pagination-left>当前显示 {{ result.records.length }} 条 · 共 {{ result.total }} 条</template>
<template #actions="{ row }"> <template #actions="{ row }"><div class="row-actions density-actions admin-text-actions">
<div class="row-actions density-actions">
<el-button v-if="access.can(actionPermission)" class="density-action" :data-tone="isReview ? 'warning' : 'purple'" size="small" @click="openTask(row as InboundOrderSummary)"> <el-button v-if="access.can(actionPermission)" class="density-action" :data-tone="isReview ? 'warning' : 'purple'" size="small" @click="openTask(row as InboundOrderSummary)">
{{ taskLabel }} {{ taskLabel }}
</el-button> </el-button>
@@ -390,8 +392,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
查看 查看
</el-button> </el-button>
<span v-if="!access.can(actionPermission) && (!access.can('platform:firstleg:inbound:view') || !(row as InboundOrderSummary).orderId)" class="muted-action">无操作权限</span> <span v-if="!access.can(actionPermission) && (!access.can('platform:firstleg:inbound:view') || !(row as InboundOrderSummary).orderId)" class="muted-action">无操作权限</span>
</div> </div></template>
</template>
<template #inboundCode="{ row }"><div class="inbound-code-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong></div></template> <template #inboundCode="{ row }"><div class="inbound-code-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong></div></template>
<template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template> <template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template>
<template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template> <template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template>
@@ -408,8 +409,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
<template #actualVolumeCbm="{ row }">{{ formatNumber((row as InboundOrderSummary).actualVolumeCbm) }}</template> <template #actualVolumeCbm="{ row }">{{ formatNumber((row as InboundOrderSummary).actualVolumeCbm) }}</template>
<template #measureCompletedAt="{ row }">{{ formatDate((row as InboundOrderSummary).measureCompletedAt) }}</template> <template #measureCompletedAt="{ row }">{{ formatDate((row as InboundOrderSummary).measureCompletedAt) }}</template>
<template #empty><el-empty :description="emptyText" /></template> <template #empty><el-empty :description="emptyText" /></template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<el-dialog <el-dialog
+2 -2
View File
@@ -5,7 +5,7 @@ export const inboundRoutes = [
path: 'inbounds/measurements', path: 'inbounds/measurements',
name: 'inbound-measurements', name: 'inbound-measurements',
component: () => import('./pages/InboundMeasurementsView.vue'), component: () => import('./pages/InboundMeasurementsView.vue'),
meta: { meta: { showPageHeading: false,
title: '订单测量', title: '订单测量',
description: '只展示当前可录入或修正测量数据的国内仓订单', description: '只展示当前可录入或修正测量数据的国内仓订单',
permission: 'platform:firstleg:inbound:measure:view', permission: 'platform:firstleg:inbound:measure:view',
@@ -15,7 +15,7 @@ export const inboundRoutes = [
path: 'inbounds/measurement-reviews', path: 'inbounds/measurement-reviews',
name: 'inbound-measurement-reviews', name: 'inbound-measurement-reviews',
component: () => import('./pages/MeasurementReviewsView.vue'), component: () => import('./pages/MeasurementReviewsView.vue'),
meta: { meta: { showPageHeading: false,
title: '测量数据审核', title: '测量数据审核',
description: '处理待审核且当前可审核的国内仓测量数据', description: '处理待审核且当前可审核的国内仓测量数据',
permission: 'firstleg:inbound:measurement-review', permission: 'firstleg:inbound:measurement-review',
@@ -135,7 +135,7 @@ describe('LoadingListsView', () => {
expect(detailSource).toContain('<StatusBadge :status="loadingListStatusLabel(summary.status)" />') expect(detailSource).toContain('<StatusBadge :status="loadingListStatusLabel(summary.status)" />')
}) })
it('uses bordered list actions with a stable 320px layout', () => { it('uses text row actions in the integrated list while retaining detail table styles', () => {
expect(source).toContain(":class=\"{ 'density-action': isPreload }\"") expect(source).toContain(":class=\"{ 'density-action': isPreload }\"")
expect(source).toContain(':type="isPreload ? undefined : \'primary\'" :plain="!isPreload"') expect(source).toContain(':type="isPreload ? undefined : \'primary\'" :plain="!isPreload"')
expect(source).not.toContain('<el-button v-if="canView" link') expect(source).not.toContain('<el-button v-if="canView" link')
@@ -143,7 +143,8 @@ describe('LoadingListsView', () => {
expect(source).toContain('flex-wrap: wrap') expect(source).toContain('flex-wrap: wrap')
expect(source).toContain('@media (max-width: 720px)') expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('.row-actions { gap: 4px; }') expect(source).toContain('.row-actions { gap: 4px; }')
expect(source).toMatch(/:loading="loading"\s+stripe/) expect(source).toContain('row-actions density-actions admin-text-actions')
expect(source).toContain('appearance="integrated" :stripe="false"')
expect(detailSource).toMatch(/:columns="itemColumns"\s+stripe/) expect(detailSource).toMatch(/:columns="itemColumns"\s+stripe/)
}) })
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ContainerSimulationCreate } from '@/modules/container-loading' import { ContainerSimulationCreate } from '@/modules/container-loading'
import { Eye } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -182,9 +182,25 @@ onMounted(() => {
<template> <template>
<AdminPage> <AdminPage>
<div class="business-page loading-lists-page" :class="{ 'admin-density': isPreload }"> <div class="business-page loading-lists-page admin-density">
<ListPageLayout :ariaLabel="title"> <ListPageLayout :ariaLabel="title">
<template #toolbar>
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="canList && !error"
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
:loading="loading"
:total="total"
:preference-key="`loading-lists-${mode}`"
row-key="planId"
:empty-text="`暂无符合筛选条件的${title}`"
:aria-label="title"
>
<template #toolbar-start><h1>{{ title }}</h1></template>
<template #filters>
<QueryToolbar <QueryToolbar
v-model="query" v-model="query"
:ariaLabel="`${title}筛选`" :ariaLabel="`${title}筛选`"
@@ -194,35 +210,20 @@ onMounted(() => {
@search="search" @search="search"
/> />
</template> </template>
<template #notices><template v-if="!canList || error">
<template v-if="!canList || error" #state>
<LoadErrorState v-if="!canList" :title="`无权查看${title}`" message="当前账号缺少该清单的列表权限。" :show-retry="false" /> <LoadErrorState v-if="!canList" :title="`无权查看${title}`" message="当前账号缺少该清单的列表权限。" :show-retry="false" />
<LoadErrorState v-else-if="error" :title="`${title}加载失败`" :message="error.message" :loading="loading" @retry="load"> <LoadErrorState v-else-if="error" :title="`${title}加载失败`" :message="error.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="error" /></template> <template #details><ApiErrorMeta :details="error" /></template>
</LoadErrorState> </LoadErrorState>
</template> </template></template>
<section v-if="canList && !error" class="surface-panel table-panel">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
:loading="loading"
stripe
:total="total"
:preference-key="`loading-lists-${mode}`"
row-key="planId"
:empty-text="`暂无符合筛选条件的${title}`"
:aria-label="title"
>
<template #toolbar-actions> <template #toolbar-actions>
<ContainerSimulationCreate :dense="isPreload" v-if="canCreate" success-message="模拟柜及预装清单已创建" @created="onSimulationCreated" /> <ContainerSimulationCreate :dense="isPreload" v-if="canCreate" success-message="模拟柜及预装清单已创建" @created="onSimulationCreated" />
</template> </template>
<template #actions="{ row }"> <template #actions="{ row }"><div class="row-actions density-actions admin-text-actions" :class="{ 'density-actions': isPreload }">
<div class="row-actions" :class="{ 'density-actions': isPreload }"> <el-button v-if="canView" :class="{ 'density-action': isPreload }" size="small" :type="isPreload ? undefined : 'primary'" :plain="!isPreload" @click="openDetail(row)">查看</el-button>
<el-button v-if="canView" :class="{ 'density-action': isPreload }" size="small" :type="isPreload ? undefined : 'primary'" :plain="!isPreload" @click="openDetail(row)"><Eye v-if="!isPreload" :size="15" />查看</el-button> </div></template>
</div>
</template>
<template #customers="{ row }"><LineClampText :content="(row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、')" /></template> <template #customers="{ row }"><LineClampText :content="(row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、')" /></template>
<template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template> <template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template>
<template #status="{ row }"><StatusBadge :status="loadingListStatusLabel(row.status)" /></template> <template #status="{ row }"><StatusBadge :status="loadingListStatusLabel(row.status)" /></template>
@@ -230,8 +231,7 @@ onMounted(() => {
<template #netWeight="{ row }">{{ formatNumber((row as ConfirmedSummary).totalNetWeightKg) }}</template> <template #netWeight="{ row }">{{ formatNumber((row as ConfirmedSummary).totalNetWeightKg) }}</template>
<template #volume="{ row }">{{ formatNumber(row.totalVolumeCbm) }}</template> <template #volume="{ row }">{{ formatNumber(row.totalVolumeCbm) }}</template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template> <template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
</div> </div>
+1 -1
View File
@@ -5,7 +5,7 @@ export const loadingListRoutes = [
path: 'loading-plans', path: 'loading-plans',
name: 'loading-plans', name: 'loading-plans',
component: () => import('./pages/PreloadPlansView.vue'), component: () => import('./pages/PreloadPlansView.vue'),
meta: { meta: { showPageHeading: false,
title: '预装清单', title: '预装清单',
description: '查看全部仓库拣货中的预装计划、货物明细和操作历史', description: '查看全部仓库拣货中的预装计划、货物明细和操作历史',
permission: 'platform:firstleg:preload:list', permission: 'platform:firstleg:preload:list',
@@ -139,12 +139,12 @@ describe('PartnersView real API integration', () => {
expect(wrapper.text()).not.toContain('往来单位档案') expect(wrapper.text()).not.toContain('往来单位档案')
expect(wrapper.find('.query-toolbar').exists()).toBe(true) expect(wrapper.find('.query-toolbar').exists()).toBe(true)
expect(wrapper.find('.data-table').exists()).toBe(true) expect(wrapper.find('.data-table').exists()).toBe(true)
expect(wrapper.find('.table-panel').exists()).toBe(true) expect(wrapper.find('.el-table').exists()).toBe(true)
expect(wrapper.find('.query-toolbar__actions').text()).not.toContain('新增往来单位') expect(wrapper.find('.query-toolbar__actions').text()).not.toContain('新增往来单位')
expect(wrapper.find('.data-table__business-actions').text()).toContain('新增往来单位') expect(wrapper.find('.data-table__business-actions').text()).toContain('新增往来单位')
expect(wrapper.find('.data-table__toolbar-start').exists()).toBe(false) expect(wrapper.get('.data-table__toolbar-start h1').text()).toBe('往来单位')
expect(wrapper.find('.data-table__pagination-left').text()).toContain('平台公共数据 · 共 1 个往来单位') expect(wrapper.find('.data-table__pagination-left').text()).toContain('平台公共数据 · 共 1 个往来单位')
expect(wrapper.find('.list-page-layout__state').exists()).toBe(false) expect(wrapper.find('.data-table__notices').exists()).toBe(false)
const detailButton = wrapper.findAll('button').find((button) => button.text().trim() === '详情') const detailButton = wrapper.findAll('button').find((button) => button.text().trim() === '详情')
await detailButton?.trigger('click') await detailButton?.trigger('click')
await flushPromises() await flushPromises()
@@ -248,8 +248,8 @@ describe('PartnersView real API integration', () => {
expect(wrapper.text()).not.toContain('Gateway unavailable') expect(wrapper.text()).not.toContain('Gateway unavailable')
expect(wrapper.text()).toContain('重新加载') expect(wrapper.text()).toContain('重新加载')
expect(wrapper.text()).not.toContain('深圳示例贸易有限公司') expect(wrapper.text()).not.toContain('深圳示例贸易有限公司')
expect(wrapper.find('.list-page-layout__state').exists()).toBe(true) expect(wrapper.find('.data-table__notices').exists()).toBe(true)
expect(wrapper.find('.table-panel').exists()).toBe(false) expect(wrapper.find('.el-table').exists()).toBe(false)
}) })
it('keeps the latest detail when an older partner request finishes last', async () => { it('keeps the latest detail when an older partner request finishes last', async () => {
@@ -373,23 +373,11 @@ onMounted(() => {
<template> <template>
<AdminPage as="div" class="partners-page admin-density" spacing="normal"> <AdminPage as="div" class="partners-page admin-density" spacing="normal">
<ListPageLayout aria-label="往来单位管理"> <ListPageLayout aria-label="往来单位管理">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="往来单位筛选" :fields="queryFields" preference-key="partners-query" @search="searchPartners" />
</template>
<template v-if="loadError" #state>
<LoadErrorState
title="往来单位列表暂时无法加载"
:message="loadError.message"
:loading="loading"
@retry="loadPartners()"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template>
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
<DataTable
<section :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page" v-model:page="page"
v-model:page-size="pageSize" v-model:page-size="pageSize"
:data="rows" :data="rows"
@@ -402,21 +390,34 @@ onMounted(() => {
row-key="id" row-key="id"
empty-text="暂无符合条件的往来单位" empty-text="暂无符合条件的往来单位"
> >
<template #toolbar-start><h1>往来单位</h1></template>
<template #filters>
<QueryToolbar v-model="filters" ariaLabel="往来单位筛选" :fields="queryFields" preference-key="partners-query" @search="searchPartners" />
</template>
<template #notices><template v-if="loadError">
<LoadErrorState
title="往来单位列表暂时无法加载"
:message="loadError.message"
:loading="loading"
@retry="loadPartners()"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template></template>
<template #toolbar-actions><div class="partners-primary-actions"><el-button v-if="access.can('platform:counterparty:import')" :disabled="loading" @click="openImport"><FileUp :size="16" />批量导入</el-button><el-button v-if="access.can('platform:counterparty:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增往来单位</el-button></div></template> <template #toolbar-actions><div class="partners-primary-actions"><el-button v-if="access.can('platform:counterparty:import')" :disabled="loading" @click="openImport"><FileUp :size="16" />批量导入</el-button><el-button v-if="access.can('platform:counterparty:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增往来单位</el-button></div></template>
<template #actions="{ row }"> <template #actions="{ row }"><div class="table-actions density-actions row-actions admin-text-actions">
<div class="table-actions density-actions">
<el-button data-tone="neutral" class="density-action" size="small" @click="openDetail(row as PlatformPartnerResource)">详情</el-button> <el-button data-tone="neutral" class="density-action" size="small" @click="openDetail(row as PlatformPartnerResource)">详情</el-button>
<el-button data-tone="neutral" class="density-action" v-if="access.can('platform:counterparty:edit')" size="small" :disabled="loading" @click="openEdit(row as PlatformPartnerResource)">编辑</el-button> <el-button data-tone="neutral" class="density-action" v-if="access.can('platform:counterparty:edit')" size="small" :disabled="loading" @click="openEdit(row as PlatformPartnerResource)">编辑</el-button>
<el-button class="density-action" v-if="access.can('platform:counterparty:status')" size="small" :disabled="loading" :data-tone="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusLoadingIds.has(String((row as PlatformPartnerResource).id))" @click="toggleStatus(row as PlatformPartnerResource)">{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button> <el-button class="density-action" v-if="access.can('platform:counterparty:status')" size="small" :disabled="loading" :data-tone="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusLoadingIds.has(String((row as PlatformPartnerResource).id))" @click="toggleStatus(row as PlatformPartnerResource)">{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div> </div></template>
</template>
<template #partner="{ row }"><div class="partner-cell"><span><Building2 :size="18" /></span><div><LineClampText :content="(row as PlatformPartnerResource).partnerName" /></div></div></template> <template #partner="{ row }"><div class="partner-cell"><span><Building2 :size="18" /></span><div><LineClampText :content="(row as PlatformPartnerResource).partnerName" /></div></div></template>
<template #type="{ row }"><div class="partner-types"><el-tag v-for="type in (row as PlatformPartnerResource).types" :key="type" effect="plain">{{ partnerTypeLabel(type) }}</el-tag></div></template> <template #type="{ row }"><div class="partner-types"><el-tag v-for="type in (row as PlatformPartnerResource).types" :key="type" effect="plain">{{ partnerTypeLabel(type) }}</el-tag></div></template>
<template #address="{ row }"><div class="partner-address"><MapPin :size="15" /><span>{{ [(row as PlatformPartnerResource).province, (row as PlatformPartnerResource).city, (row as PlatformPartnerResource).district, (row as PlatformPartnerResource).addressLine].filter(Boolean).join(' ') }}</span></div></template> <template #address="{ row }"><div class="partner-address"><MapPin :size="15" /><span>{{ [(row as PlatformPartnerResource).province, (row as PlatformPartnerResource).city, (row as PlatformPartnerResource).district, (row as PlatformPartnerResource).addressLine].filter(Boolean).join(' ') }}</span></div></template>
<template #status="{ row }"><el-tag :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template> <template #status="{ row }"><el-tag :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
<template #pagination-left>平台公共数据 · 共 {{ total }} 个往来单位</template> <template #pagination-left>平台公共数据 · 共 {{ total }} 个往来单位</template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<AppDrawer <AppDrawer
@@ -111,7 +111,7 @@ describe('ExpressCompaniesView query and table migration', () => {
expect(wrapper.text()).toContain('顺丰速运') expect(wrapper.text()).toContain('顺丰速运')
expect(wrapper.text()).toContain('平台公共数据 · 共 1 家快递公司') expect(wrapper.text()).toContain('平台公共数据 · 共 1 家快递公司')
expect(wrapper.find('.metric-strip').exists()).toBe(false) expect(wrapper.find('.metric-strip').exists()).toBe(false)
expect(wrapper.find('.list-page-layout__state').exists()).toBe(false) expect(wrapper.find('.data-table__notices').exists()).toBe(false)
expect(listPlatformExpressCompanies).toHaveBeenCalledWith(1, 20, '', '') expect(listPlatformExpressCompanies).toHaveBeenCalledWith(1, 20, '', '')
expect(wrapper.find('.data-table').text()).not.toContain('轨迹查询模板') expect(wrapper.find('.data-table').text()).not.toContain('轨迹查询模板')
expect(wrapper.find('.data-table').text()).not.toContain('测试数据') expect(wrapper.find('.data-table').text()).not.toContain('测试数据')
@@ -121,8 +121,8 @@ describe('ExpressCompaniesView query and table migration', () => {
vi.mocked(listPlatformExpressCompanies).mockRejectedValueOnce(new Error('Gateway unavailable')) vi.mocked(listPlatformExpressCompanies).mockRejectedValueOnce(new Error('Gateway unavailable'))
const wrapper = await mountView() const wrapper = await mountView()
expect(wrapper.find('.list-page-layout__state').exists()).toBe(true) expect(wrapper.find('.data-table__notices').exists()).toBe(true)
expect(wrapper.find('.table-panel').exists()).toBe(false) expect(wrapper.find('.el-table').exists()).toBe(false)
expect(wrapper.text()).toContain('快递公司加载失败') expect(wrapper.text()).toContain('快递公司加载失败')
expect(wrapper.text()).not.toContain('Gateway unavailable') expect(wrapper.text()).not.toContain('Gateway unavailable')
}) })
@@ -284,20 +284,10 @@ onMounted(() => {
<AdminPage class="admin-density"> <AdminPage class="admin-density">
<div class="business-page express-company-page"> <div class="business-page express-company-page">
<ListPageLayout ariaLabel="快递公司列表"> <ListPageLayout ariaLabel="快递公司列表">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="快递公司筛选" :fields="queryFields" preference-key="express-companies-query" @search="applyFilters" />
</template>
<template v-if="loadError" #state>
<el-alert type="error" :closable="false" show-icon class="state-alert">
<template #title>快递公司加载失败</template>
<p>{{ loadError.message }}</p>
<ApiErrorMeta :details="loadError" />
<el-button size="small" @click="loadExpressCompanies()">重新查询</el-button>
</el-alert>
</template>
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page" v-model:page="page"
v-model:page-size="pageSize" v-model:page-size="pageSize"
:data="rows" :data="rows"
@@ -310,8 +300,22 @@ onMounted(() => {
row-key="id" row-key="id"
:empty-text="appliedFilters.keyword.trim() || appliedFilters.status ? '暂无符合筛选条件的快递公司' : '暂无快递公司档案'" :empty-text="appliedFilters.keyword.trim() || appliedFilters.status ? '暂无符合筛选条件的快递公司' : '暂无快递公司档案'"
> >
<template #toolbar-start><h1>快递公司</h1></template>
<template #filters>
<QueryToolbar v-model="filters" ariaLabel="快递公司筛选" :fields="queryFields" preference-key="express-companies-query" @search="applyFilters" />
</template>
<template #notices><template v-if="loadError">
<el-alert type="error" :closable="false" show-icon class="state-alert">
<template #title>快递公司加载失败</template>
<p>{{ loadError.message }}</p>
<ApiErrorMeta :details="loadError" />
<el-button size="small" @click="loadExpressCompanies()">重新查询</el-button>
</el-alert>
</template></template>
<template #toolbar-actions><div class="table-actions"><el-button v-if="access.can('platform:express:import')" :disabled="loading" @click="openImport"><FileUp :size="16" />批量导入</el-button><el-button v-if="access.can('platform:express:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增快递公司</el-button></div></template> <template #toolbar-actions><div class="table-actions"><el-button v-if="access.can('platform:express:import')" :disabled="loading" @click="openImport"><FileUp :size="16" />批量导入</el-button><el-button v-if="access.can('platform:express:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增快递公司</el-button></div></template>
<template #actions="{ row }"> <template #actions="{ row }"><div class="row-actions density-actions admin-text-actions">
<div v-if="access.can('platform:express:edit') || access.can('platform:express:status')" class="table-actions density-actions"> <div v-if="access.can('platform:express:edit') || access.can('platform:express:status')" class="table-actions density-actions">
<el-button data-tone="neutral" class="density-action" <el-button data-tone="neutral" class="density-action"
v-if="access.can('platform:express:edit')" v-if="access.can('platform:express:edit')"
@@ -333,7 +337,7 @@ onMounted(() => {
>{{ (row as PlatformExpressCompanyResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button> >{{ (row as PlatformExpressCompanyResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div> </div>
<span v-else class="muted-value">仅查看</span> <span v-else class="muted-value">仅查看</span>
</template> </div></template>
<template #company="{ row }"><div class="express-company-cell"><span><Truck :size="17" /></span><div><strong>{{ (row as PlatformExpressCompanyResource).companyName }}</strong></div></div></template> <template #company="{ row }"><div class="express-company-cell"><span><Truck :size="17" /></span><div><strong>{{ (row as PlatformExpressCompanyResource).companyName }}</strong></div></div></template>
<template #website="{ row }"> <template #website="{ row }">
<a v-if="safeHttpUrl((row as PlatformExpressCompanyResource).website)" class="external-link" :href="safeHttpUrl((row as PlatformExpressCompanyResource).website)!" target="_blank" rel="noopener noreferrer">访问官网<ExternalLink :size="13" /></a> <a v-if="safeHttpUrl((row as PlatformExpressCompanyResource).website)" class="external-link" :href="safeHttpUrl((row as PlatformExpressCompanyResource).website)!" target="_blank" rel="noopener noreferrer">访问官网<ExternalLink :size="13" /></a>
@@ -346,8 +350,7 @@ onMounted(() => {
</el-empty> </el-empty>
</template> </template>
<template #pagination-left="{ total: tableTotal }"><span>平台公共数据 · 共 {{ tableTotal }} 家快递公司</span></template> <template #pagination-left="{ total: tableTotal }"><span>平台公共数据 · 共 {{ tableTotal }} 家快递公司</span></template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<BatchImportDialog class="admin-density-dialog" <BatchImportDialog class="admin-density-dialog"
+2 -2
View File
@@ -5,12 +5,12 @@ export const masterDataRoutes = [
path: 'master-data/express-companies', path: 'master-data/express-companies',
name: 'express-companies', name: 'express-companies',
component: () => import('@/modules/master-data/express-companies/pages/ExpressCompaniesView.vue'), component: () => import('@/modules/master-data/express-companies/pages/ExpressCompaniesView.vue'),
meta: { title: '快递公司', description: '查询平台可用的快递公司档案与公开联系信息', permission: 'platform:express:list' }, meta: { showPageHeading: false, title: '快递公司', description: '查询平台可用的快递公司档案与公开联系信息', permission: 'platform:express:list' },
}, },
{ {
path: 'master-data/counterparties', path: 'master-data/counterparties',
name: 'counterparties', name: 'counterparties',
component: () => import('@/modules/master-data/counterparties/pages/PartnersView.vue'), component: () => import('@/modules/master-data/counterparties/pages/PartnersView.vue'),
meta: { title: '往来单位', description: '维护客户、供应商及客户/供应商双重类型单位档案', permission: 'platform:counterparty:list' }, meta: { showPageHeading: false, title: '往来单位', description: '维护客户、供应商及客户/供应商双重类型单位档案', permission: 'platform:counterparty:list' },
}, },
] satisfies RouteRecordRaw[] ] satisfies RouteRecordRaw[]
@@ -18,7 +18,12 @@ function search(){page.value===1?void load():page.value=1}
async function action(row:NotificationTask,type:'cancel'|'retry'){try{await ElMessageBox.confirm(type==='cancel'?'确定取消该消息任务吗?':'确定重试该消息任务吗?',type==='cancel'?'取消任务':'重试任务',{type:'warning',customClass:'admin-density-confirm'});actionId.value=row.id;if(type==='cancel')await cancelTask(row.id);else await retryTask(row.id);ElMessage.success(type==='cancel'?'任务已取消':'任务已提交重试');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(getApiErrorMessage(e,type==='cancel'?'取消失败':'重试失败'))}finally{actionId.value=''}} async function action(row:NotificationTask,type:'cancel'|'retry'){try{await ElMessageBox.confirm(type==='cancel'?'确定取消该消息任务吗?':'确定重试该消息任务吗?',type==='cancel'?'取消任务':'重试任务',{type:'warning',customClass:'admin-density-confirm'});actionId.value=row.id;if(type==='cancel')await cancelTask(row.id);else await retryTask(row.id);ElMessage.success(type==='cancel'?'任务已取消':'任务已提交重试');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(getApiErrorMessage(e,type==='cancel'?'取消失败':'重试失败'))}finally{actionId.value=''}}
watch([page,pageSize],load);onMounted(()=>{void load();void loadTemplates()}) watch([page,pageSize],load);onMounted(()=>{void load();void loadTemplates()})
</script> </script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="消息管理列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="消息任务筛选" :fields="queryFields" preference-key="notifications-messages-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" preference-key="notifications-messages-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息任务"><template #toolbar-actions><el-button v-if="access.can('platform:notification:create')" type="primary" :disabled="!templates.length" @click="composerOpen=true"><Plus :size="16"/>新建消息</el-button></template><template #pagination-left><span>共 {{ result.total }} 个任务</span></template><template #actions="{row}"><div class="density-actions"><el-button class="density-action" data-tone="neutral" @click="openDetail(row as NotificationTask)">查看</el-button><el-button v-if="access.can('platform:notification:task:cancel')&&['CREATED','SCHEDULED'].includes((row as NotificationTask).status)" class="density-action" data-tone="danger" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'cancel')">取消</el-button><el-button v-if="access.can('platform:notification:task:retry')&&['RETRYING','DEAD'].includes((row as NotificationTask).status)" class="density-action" data-tone="neutral" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'retry')">重试</el-button></div></template><template #category="{row}">{{notificationCategoryLabel((row as NotificationTask).category)}}</template><template #scheduledAt="{row}">{{formatDate((row as NotificationTask).scheduledAt)}}</template><template #sentAt="{row}">{{formatDate((row as NotificationTask).sentAt)}}</template><template #status="{row}"><el-tag>{{statusLabel((row as NotificationTask).status)}}</el-tag></template></DataTable></section></ListPageLayout><MessageComposerDialog v-model="composerOpen" :templates="templates" @success="load"/><AppDrawer class="admin-density admin-density-drawer" v-model="detailOpen" title="消息任务详情" size="min(820px, 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><template v-else-if="detail"><dl class="detail-grid density-fields"><div><dt>任务号</dt><dd>{{detail.taskNo}}</dd></div><div><dt>状态</dt><dd>{{statusLabel(detail.status)}}</dd></div><div><dt>业务分类</dt><dd>{{notificationCategoryLabel(detail.category)}}</dd></div><div><dt>接收人数</dt><dd>{{detail.recipientCount}}</dd></div><div><dt>尝试次数</dt><dd>{{detail.attemptCount}}</dd></div><div><dt>发送时间</dt><dd>{{formatDate(detail.sentAt)}}</dd></div></dl><section v-if="detail.lastError" class="failure-panel" role="alert"><strong>任务失败原因</strong><p>{{detail.lastError}}</p></section><h3 class="recipient-heading">收件人投递明细</h3><el-table :data="detail.recipients" row-key="id" empty-text="暂无收件人明细" size="small"><el-table-column prop="displayName" label="收件人" min-width="150"/><el-table-column label="应用端" width="110"><template #default="{row}">{{notificationAppLabel(row.appCode)}}</template></el-table-column><el-table-column label="投递状态" width="120"><template #default="{row}">{{notificationRecipientStatusLabel(row.status)}}</template></el-table-column><el-table-column label="送达时间" width="180"><template #default="{row}">{{formatDate(row.deliveredAt)}}</template></el-table-column><el-table-column prop="failureReason" label="失败原因" min-width="220"><template #default="{row}">{{row.failureReason||'—'}}</template></el-table-column></el-table></template></AppDrawer></AdminPage></template> <template><AdminPage class="admin-density"><ListPageLayout aria-label="消息管理列表"><section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-messages-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息任务">
<template #toolbar-start><h1>消息管理</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="消息任务筛选" :fields="queryFields" preference-key="notifications-messages-query" @search="search" /></template>
<template #notices><template v-if="error"><LoadErrorState title="消息任务加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="access.can('platform:notification:create')" type="primary" :disabled="!templates.length" @click="composerOpen=true"><Plus :size="16"/>新建消息</el-button></template><template #pagination-left><span>共 {{ result.total }} 个任务</span></template><template #actions="{row}"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" @click="openDetail(row as NotificationTask)">查看</el-button><el-button v-if="access.can('platform:notification:task:cancel')&&['CREATED','SCHEDULED'].includes((row as NotificationTask).status)" class="density-action" data-tone="danger" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'cancel')">取消</el-button><el-button v-if="access.can('platform:notification:task:retry')&&['RETRYING','DEAD'].includes((row as NotificationTask).status)" class="density-action" data-tone="neutral" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'retry')">重试</el-button></div></template><template #category="{row}">{{notificationCategoryLabel((row as NotificationTask).category)}}</template><template #scheduledAt="{row}">{{formatDate((row as NotificationTask).scheduledAt)}}</template><template #sentAt="{row}">{{formatDate((row as NotificationTask).sentAt)}}</template><template #status="{row}"><el-tag>{{statusLabel((row as NotificationTask).status)}}</el-tag></template></DataTable></section></ListPageLayout><MessageComposerDialog v-model="composerOpen" :templates="templates" @success="load"/><AppDrawer class="admin-density admin-density-drawer" v-model="detailOpen" title="消息任务详情" size="min(820px, 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><template v-else-if="detail"><dl class="detail-grid density-fields"><div><dt>任务号</dt><dd>{{detail.taskNo}}</dd></div><div><dt>状态</dt><dd>{{statusLabel(detail.status)}}</dd></div><div><dt>业务分类</dt><dd>{{notificationCategoryLabel(detail.category)}}</dd></div><div><dt>接收人数</dt><dd>{{detail.recipientCount}}</dd></div><div><dt>尝试次数</dt><dd>{{detail.attemptCount}}</dd></div><div><dt>发送时间</dt><dd>{{formatDate(detail.sentAt)}}</dd></div></dl><section v-if="detail.lastError" class="failure-panel" role="alert"><strong>任务失败原因</strong><p>{{detail.lastError}}</p></section><h3 class="recipient-heading">收件人投递明细</h3><el-table :data="detail.recipients" row-key="id" empty-text="暂无收件人明细" size="small"><el-table-column prop="displayName" label="收件人" min-width="150"/><el-table-column label="应用端" width="110"><template #default="{row}">{{notificationAppLabel(row.appCode)}}</template></el-table-column><el-table-column label="投递状态" width="120"><template #default="{row}">{{notificationRecipientStatusLabel(row.status)}}</template></el-table-column><el-table-column label="送达时间" width="180"><template #default="{row}">{{formatDate(row.deliveredAt)}}</template></el-table-column><el-table-column prop="failureReason" label="失败原因" min-width="220"><template #default="{row}">{{row.failureReason||'—'}}</template></el-table-column></el-table></template></AppDrawer></AdminPage></template>
<style scoped> <style scoped>
.detail-grid.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: var(--admin-space-6); } .detail-grid.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: var(--admin-space-6); }
.failure-panel { margin-bottom: var(--admin-space-6); padding: var(--admin-space-5); border: 1px solid #f2b8b5; border-radius: var(--admin-radius); background: #fff7f6; color: #9f2d27; } .failure-panel { margin-bottom: var(--admin-space-6); padding: var(--admin-space-5); border: 1px solid #f2b8b5; border-radius: var(--admin-radius); background: #fff7f6; color: #9f2d27; }
@@ -11,4 +11,9 @@ function search(){page.value===1?void load():page.value=1}function create(){edit
async function changeStatus(row:NotificationTemplate){const target=row.status==='DRAFT'?'ACTIVE':'DISABLED';try{await ElMessageBox.confirm(target==='ACTIVE'?'激活后模板不可再编辑,确定继续吗?':'禁用后首期不可重新启用,确定继续吗?',target==='ACTIVE'?'激活模板':'禁用模板',{type:'warning',customClass:'admin-density-confirm'});actionId.value=row.id;await changeTemplateStatus(row.id,target,row.version);ElMessage.success(target==='ACTIVE'?'模板已激活':'模板已禁用');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(getApiErrorMessage(e,'模板状态变更失败'))}finally{actionId.value=''}} async function changeStatus(row:NotificationTemplate){const target=row.status==='DRAFT'?'ACTIVE':'DISABLED';try{await ElMessageBox.confirm(target==='ACTIVE'?'激活后模板不可再编辑,确定继续吗?':'禁用后首期不可重新启用,确定继续吗?',target==='ACTIVE'?'激活模板':'禁用模板',{type:'warning',customClass:'admin-density-confirm'});actionId.value=row.id;await changeTemplateStatus(row.id,target,row.version);ElMessage.success(target==='ACTIVE'?'模板已激活':'模板已禁用');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(getApiErrorMessage(e,'模板状态变更失败'))}finally{actionId.value=''}}
watch([page,pageSize],load);onMounted(load) watch([page,pageSize],load);onMounted(load)
</script> </script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="消息模板列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="消息模板筛选" :fields="queryFields" preference-key="notifications-templates-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" preference-key="notifications-templates-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息模板"><template #toolbar-actions><el-button v-if="access.can('platform:notification:template:create')" type="primary" @click="create"><Plus :size="16"/>新增模板</el-button></template><template #pagination-left><span>共 {{result.total}} 个模板</span></template><template #actions="{row}"><div class="density-actions"><el-button v-if="access.can('platform:notification:template:edit')&&(row as NotificationTemplate).status==='DRAFT'" class="density-action" data-tone="neutral" @click="edit(row as NotificationTemplate)">编辑</el-button><el-button v-if="access.can('platform:notification:template:status')&&(row as NotificationTemplate).status!=='DISABLED'" class="density-action" :data-tone="(row as NotificationTemplate).status==='ACTIVE'?'danger':'success'" :loading="actionId===(row as NotificationTemplate).id" @click="changeStatus(row as NotificationTemplate)">{{(row as NotificationTemplate).status==='DRAFT'?'激活':'禁用'}}</el-button></div></template><template #status="{row}"><el-tag>{{statusLabel((row as NotificationTemplate).status)}}</el-tag></template></DataTable></section></ListPageLayout><TemplateEditorDialog v-model="editorOpen" :template="editing" @success="load"/></AdminPage></template> <template><AdminPage class="admin-density"><ListPageLayout aria-label="消息模板列表"><section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-templates-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息模板">
<template #toolbar-start><h1>模板管理</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="消息模板筛选" :fields="queryFields" preference-key="notifications-templates-query" @search="search"/></template>
<template #notices><template v-if="error"><LoadErrorState title="模板加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error"/></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="access.can('platform:notification:template:create')" type="primary" @click="create"><Plus :size="16"/>新增模板</el-button></template><template #pagination-left><span>共 {{result.total}} 个模板</span></template><template #actions="{row}"><div class="density-actions row-actions admin-text-actions"><el-button v-if="access.can('platform:notification:template:edit')&&(row as NotificationTemplate).status==='DRAFT'" class="density-action" data-tone="neutral" @click="edit(row as NotificationTemplate)">编辑</el-button><el-button v-if="access.can('platform:notification:template:status')&&(row as NotificationTemplate).status!=='DISABLED'" class="density-action" :data-tone="(row as NotificationTemplate).status==='ACTIVE'?'danger':'success'" :loading="actionId===(row as NotificationTemplate).id" @click="changeStatus(row as NotificationTemplate)">{{(row as NotificationTemplate).status==='DRAFT'?'激活':'禁用'}}</el-button></div></template><template #status="{row}"><el-tag>{{statusLabel((row as NotificationTemplate).status)}}</el-tag></template></DataTable></section></ListPageLayout><TemplateEditorDialog v-model="editorOpen" :template="editing" @success="load"/></AdminPage></template>
@@ -81,18 +81,21 @@ onMounted(() => void load())
<template> <template>
<AdminPage class="admin-density"> <AdminPage class="admin-density">
<ListPageLayout aria-label="我的消息列表"> <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"> <section><DataTable preference-key="notifications-platformnotificationsview" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息">
<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-start><h1>我的消息</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="我的消息筛选" :fields="queryFields" preference-key="notifications-inbox-query" @search="search" /></template>
<template #notices><template v-if="error"><LoadErrorState title="我的消息加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="canReadAll && unreadCount" type="primary" plain :disabled="loading" @click="markAllRead"><CheckCheck :size="16" />全部标为已读</el-button></template> <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 #pagination-left><span>共 {{ result.total }} 条<span v-if="unreadCount">,未读 {{ unreadCount }} 条</span></span></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" @click="openDetail(row as NotificationInboxItem)">查看</el-button><el-button v-if="canRead && (row as NotificationInboxItem).status === 'UNREAD'" class="density-action" data-tone="neutral" @click="markRead(row as NotificationInboxItem)">标为已读</el-button></div></template> <template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" @click="openDetail(row as NotificationInboxItem)">查看</el-button><el-button v-if="canRead && (row as NotificationInboxItem).status === 'UNREAD'" class="density-action" data-tone="neutral" @click="markRead(row as NotificationInboxItem)">标为已读</el-button></div></template>
<template #category="{ row }">{{ notificationCategoryLabel((row as NotificationInboxItem).category) }}</template> <template #category="{ row }">{{ notificationCategoryLabel((row as NotificationInboxItem).category) }}</template>
<template #sentAt="{ row }">{{ formatDate((row as NotificationInboxItem).sentAt) }}</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> <template #status="{ row }"><el-tag :type="(row as NotificationInboxItem).status === 'UNREAD' ? 'danger' : 'info'">{{ statusLabel((row as NotificationInboxItem).status) }}</el-tag></template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<AppDrawer class="admin-density admin-density-drawer" v-model="detailOpen" title="消息详情" size="min(680px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close> <AppDrawer class="admin-density admin-density-drawer" 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> <LoadErrorState v-if="detailError" title="消息详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
+3 -3
View File
@@ -5,18 +5,18 @@ export const notificationRoutes = [
path: 'notifications', path: 'notifications',
name: 'notification-inbox', name: 'notification-inbox',
component: () => import('@/modules/notifications/pages/PlatformNotificationsView.vue'), component: () => import('@/modules/notifications/pages/PlatformNotificationsView.vue'),
meta: { title: '我的消息', description: '查看当前平台账号的站内消息', permission: 'platform:notification:view' }, meta: { showPageHeading: false, title: '我的消息', description: '查看当前平台账号的站内消息', permission: 'platform:notification:view' },
}, },
{ {
path: 'notifications/messages', path: 'notifications/messages',
name: 'notification-messages', name: 'notification-messages',
component: () => import('@/modules/notifications/pages/MessageManagementView.vue'), component: () => import('@/modules/notifications/pages/MessageManagementView.vue'),
meta: { title: '消息管理', description: '创建并管理平台站内消息', permission: 'platform:notification:task:list' }, meta: { showPageHeading: false, title: '消息管理', description: '创建并管理平台站内消息', permission: 'platform:notification:task:list' },
}, },
{ {
path: 'notifications/templates', path: 'notifications/templates',
name: 'notification-templates', name: 'notification-templates',
component: () => import('@/modules/notifications/pages/MessageTemplatesView.vue'), component: () => import('@/modules/notifications/pages/MessageTemplatesView.vue'),
meta: { title: '模板管理', description: '维护平台站内消息模板', permission: 'platform:notification:template:list' }, meta: { showPageHeading: false, title: '模板管理', description: '维护平台站内消息模板', permission: 'platform:notification:template:list' },
}, },
] satisfies RouteRecordRaw[] ] satisfies RouteRecordRaw[]
@@ -71,19 +71,22 @@ onActivated(() => void load())
<template> <template>
<AdminPage as="div" class="ocean-bookings-page admin-density"> <AdminPage as="div" class="ocean-bookings-page admin-density">
<ListPageLayout aria-label="订舱列表"> <ListPageLayout aria-label="订舱列表">
<template #toolbar><QueryToolbar v-model="query" class="surface-panel" ariaLabel="订舱筛选" :fields="fields" @search="search" /></template>
<template v-if="error" #state><LoadErrorState title="订舱列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel"> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱"> <template #toolbar-start><h1>订舱列表</h1></template>
<template #filters><QueryToolbar v-model="query" ariaLabel="订舱筛选" :fields="fields" @search="search" /></template>
<template #notices><template v-if="error"><LoadErrorState title="订舱列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="createBooking"><Plus :size="16" />新增订舱</el-button></template> <template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="createBooking"><Plus :size="16" />新增订舱</el-button></template>
<template #actions="{ row }"><div class="table-actions density-actions"><el-button data-tone="primary" class="density-action" size="small" type="primary" plain @click="open(row.id)">{{ row.status === 'DRAFT' ? '编辑' : '查看' }}</el-button><el-tooltip :content="printHint()" placement="top"><span><el-button class="density-action" size="small" plain :disabled="!canPrint" @click="openPrint(row)">打印单据</el-button></span></el-tooltip></div></template> <template #actions="{ row }"><div class="table-actions density-actions row-actions admin-text-actions"><el-button data-tone="primary" class="density-action" size="small" type="primary" plain @click="open(row.id)">{{ row.status === 'DRAFT' ? '编辑' : '查看' }}</el-button><el-tooltip :content="printHint()" placement="top"><span><el-button class="density-action" size="small" plain :disabled="!canPrint" @click="openPrint(row)">打印单据</el-button></span></el-tooltip></div></template>
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template> <template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template> <template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>
<template #eta="{ row }">{{ formatDateTime(row.eta) }}</template> <template #eta="{ row }">{{ formatDateTime(row.eta) }}</template>
<template #status="{ row }"><StatusBadge :status="oceanBookingStatusLabel(row.status)" /></template> <template #status="{ row }"><StatusBadge :status="oceanBookingStatusLabel(row.status)" /></template>
<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template> <template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<OceanBookingPrintDialog v-if="printBooking" v-model="printOpen" :booking-id="printBooking.id" :booking-no="printBooking.bookingNo" :allowed="canPrint" /> <OceanBookingPrintDialog v-if="printBooking" v-model="printOpen" :booking-id="printBooking.id" :booking-no="printBooking.bookingNo" :allowed="canPrint" />
</AdminPage> </AdminPage>
+1 -1
View File
@@ -1,2 +1,2 @@
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
export const oceanBookingRoutes = [{ path: 'ocean-bookings', name: 'ocean-bookings', component: () => import('./pages/OceanBookingsView.vue'), meta: { title: '海运订舱', description: '维护海运订舱申请、船期与放舱状态', permission: 'platform:firstleg:ocean-booking:list' } }, { path: 'ocean-bookings/new', name: 'ocean-booking-create', component: () => import('./pages/OceanBookingDetailView.vue'), meta: { title: '新增订舱', description: '登记线下已确认的真实海运订舱', permission: 'platform:firstleg:ocean-booking:create' } }, { path: 'ocean-bookings/:bookingId', name: 'ocean-booking-detail', component: () => import('./pages/OceanBookingDetailView.vue'), meta: { title: '订舱详情', description: '查看和维护订舱详情', permission: 'platform:firstleg:ocean-booking:list' } }] satisfies RouteRecordRaw[] export const oceanBookingRoutes = [{ path: 'ocean-bookings', name: 'ocean-bookings', component: () => import('./pages/OceanBookingsView.vue'), meta: { showPageHeading: false, title: '海运订舱', description: '维护海运订舱申请、船期与放舱状态', permission: 'platform:firstleg:ocean-booking:list' } }, { path: 'ocean-bookings/new', name: 'ocean-booking-create', component: () => import('./pages/OceanBookingDetailView.vue'), meta: { title: '新增订舱', description: '登记线下已确认的真实海运订舱', permission: 'platform:firstleg:ocean-booking:create' } }, { path: 'ocean-bookings/:bookingId', name: 'ocean-booking-detail', component: () => import('./pages/OceanBookingDetailView.vue'), meta: { title: '订舱详情', description: '查看和维护订舱详情', permission: 'platform:firstleg:ocean-booking:list' } }] satisfies RouteRecordRaw[]
@@ -61,10 +61,15 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
<template> <template>
<AdminPage class="admin-density"><ListPageLayout aria-label="海外采购订单列表"> <AdminPage class="admin-density"><ListPageLayout aria-label="海外采购订单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="search" /></template>
<template v-if="loadError" #state><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-business-purchases" row-key="id" empty-text="暂无符合筛选条件的海外采购订单"> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-business-purchases" row-key="id" empty-text="暂无符合筛选条件的海外采购订单">
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as OverseasPurchaseSummary)">详情</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(overseasBusinessPermissions.purchaseEdit)" size="small" @click="open(row as OverseasPurchaseSummary, true)">编辑</el-button></div></template> <template #toolbar-start><h1>海外采购订单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="search" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as OverseasPurchaseSummary)">详情</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(overseasBusinessPermissions.purchaseEdit)" size="small" @click="open(row as OverseasPurchaseSummary, true)">编辑</el-button></div></template>
<template #pagination-left>共 {{ total }} 条海外采购订单</template> <template #pagination-left>共 {{ total }} 条海外采购订单</template>
</DataTable></section> </DataTable></section>
</ListPageLayout></AdminPage> </ListPageLayout></AdminPage>
@@ -76,11 +76,16 @@ watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page
<template> <template>
<AdminPage class="admin-density"><ListPageLayout :aria-label="`${domainLabel}订单列表`"> <AdminPage class="admin-density"><ListPageLayout :aria-label="`${domainLabel}订单列表`">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="search" /></template>
<template v-if="loadError" #state><LoadErrorState :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="`overseas-business-${domain}`" row-key="id" :empty-text="`暂无符合筛选条件的${domainLabel}订单`"> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="`overseas-business-${domain}`" row-key="id" :empty-text="`暂无符合筛选条件的${domainLabel}订单`">
<template #toolbar-start><h1>{{ domainLabel }}</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="search" /></template>
<template #notices><template v-if="loadError"><LoadErrorState :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template> <template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template>
<template #actions="{ row }"><el-tooltip v-if="access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></template> <template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-tooltip v-if="access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template> <template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
</DataTable></section> </DataTable></section>
</ListPageLayout></AdminPage> </ListPageLayout></AdminPage>
+3 -3
View File
@@ -6,7 +6,7 @@ export const overseasBusinessRoutes = [
path: 'overseas-business/purchases', path: 'overseas-business/purchases',
name: 'overseas-business-purchases', name: 'overseas-business-purchases',
component: () => import('./pages/PurchaseOrdersView.vue'), component: () => import('./pages/PurchaseOrdersView.vue'),
meta: { title: '海外采购订单', description: '查询海外采购订单与库存变化', permission: permission.purchaseList }, meta: { showPageHeading: false, title: '海外采购订单', description: '查询海外采购订单与库存变化', permission: permission.purchaseList },
}, },
{ {
path: 'overseas-business/purchases/:purchaseId', path: 'overseas-business/purchases/:purchaseId',
@@ -25,7 +25,7 @@ export const overseasBusinessRoutes = [
name: 'overseas-business-trucks', name: 'overseas-business-trucks',
component: () => import('./pages/TransportOrdersView.vue'), component: () => import('./pages/TransportOrdersView.vue'),
props: { domain: 'truck' }, props: { domain: 'truck' },
meta: { title: '本土卡车', description: '管理海外本土卡车订单', permission: permission.truckList }, meta: { showPageHeading: false, title: '本土卡车', description: '管理海外本土卡车订单', permission: permission.truckList },
}, },
{ {
path: 'overseas-business/trucks/new', path: 'overseas-business/trucks/new',
@@ -46,7 +46,7 @@ export const overseasBusinessRoutes = [
name: 'overseas-business-looseboards', name: 'overseas-business-looseboards',
component: () => import('./pages/TransportOrdersView.vue'), component: () => import('./pages/TransportOrdersView.vue'),
props: { domain: 'looseboard' }, props: { domain: 'looseboard' },
meta: { title: '本土散板', description: '管理海外本土散板订单', permission: permission.looseboardList }, meta: { showPageHeading: false, title: '本土散板', description: '管理海外本土散板订单', permission: permission.looseboardList },
}, },
{ {
path: 'overseas-business/looseboards/new', path: 'overseas-business/looseboards/new',
@@ -45,13 +45,18 @@ watch([page, pageSize], load); onMounted(load)
</script> </script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="汇率管理"> <template><AdminPage class="admin-density"><ListPageLayout aria-label="汇率管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="汇率月份筛选" preference-key="overseas-exchange-rates-query" :loading="loading" @search="search" /></template>
<template #metrics><dl class="rate-summary density-detail-overview"><div v-for="item in [{label:'最新维护汇率',value:summary.latestUsdToCnyAverage??'—'},{label:'汇率月份',value:summary.latestMonth||'—'},{label:'最后更新人',value:summary.updatedBy||'—'},{label:'最后更新时间',value:summary.updatedAt||'—'}]" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div></dl></template>
<template #state><LoadErrorState v-if="error" title="月度汇率加载失败" :message="error.message" @retry="load" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="month" preference-key="overseas-exchange-rates-table" empty-text="暂无月度汇率"> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="month" preference-key="overseas-exchange-rates-table" empty-text="暂无月度汇率">
<template #toolbar-start><h1>汇率管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="汇率月份筛选" preference-key="overseas-exchange-rates-query" :loading="loading" @search="search" /></template>
<template #notices><LoadErrorState v-if="error" title="月度汇率加载失败" :message="error.message" @retry="load" /></template>
<template #before-results><dl class="rate-summary density-detail-overview"><div v-for="item in [{label:'最新维护汇率',value:summary.latestUsdToCnyAverage??'—'},{label:'汇率月份',value:summary.latestMonth||'—'},{label:'最后更新人',value:summary.updatedBy||'—'},{label:'最后更新时间',value:summary.updatedAt||'—'}]" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div></dl></template>
<template #toolbar-actions><el-button v-if="access.can(permission.exchangeRateCreate)" type="primary" @click="openCreate"><Plus :size="16" />更新月度汇率</el-button><el-button v-if="access.can(permission.exchangeRateExport)" :loading="exporting" @click="exportCsv"><Download :size="16" />导出</el-button></template> <template #toolbar-actions><el-button v-if="access.can(permission.exchangeRateCreate)" type="primary" @click="openCreate"><Plus :size="16" />更新月度汇率</el-button><el-button v-if="access.can(permission.exchangeRateExport)" :loading="exporting" @click="exportCsv"><Download :size="16" />导出</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.exchangeRateEdit)" @click="openEdit(row as MonthlyExchangeRate)">编辑</el-button><el-button class="density-action" v-if="access.can(permission.exchangeRateStatus)" :data-tone="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggle(row as MonthlyExchangeRate)">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '停用' : '启用' }}</el-button></div></template> <template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.exchangeRateEdit)" @click="openEdit(row as MonthlyExchangeRate)">编辑</el-button><el-button class="density-action" v-if="access.can(permission.exchangeRateStatus)" :data-tone="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggle(row as MonthlyExchangeRate)">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '停用' : '启用' }}</el-button></div></template>
<template #conversion="{ row }">1 USD = {{ (row as MonthlyExchangeRate).usdToCnyAverage }} CNY</template><template #status="{ row }"><el-tag :type="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'success' : 'info'">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template> <template #conversion="{ row }">1 USD = {{ (row as MonthlyExchangeRate).usdToCnyAverage }} CNY</template><template #status="{ row }"><el-tag :type="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'success' : 'info'">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
</DataTable></section> </DataTable></section>
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editingMonth ? '编辑月度汇率' : '更新月度汇率'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form class="density-form" label-position="top"><el-form-item label="汇率月份"><el-date-picker v-model="form.month" type="month" value-format="YYYY-MM" :disabled="!!editingMonth" /></el-form-item><el-form-item label="美元兑人民币平均汇率"><el-input-number v-model="form.usdToCnyAverage" :min="0.00000001" :precision="8" placeholder="请输入美元兑人民币月均汇率" /><p>汇率方向:1 USD = X CNY</p></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="请输入备注(选填)" /></el-form-item></el-form></AppDialog> <AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editingMonth ? '编辑月度汇率' : '更新月度汇率'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form class="density-form" label-position="top"><el-form-item label="汇率月份"><el-date-picker v-model="form.month" type="month" value-format="YYYY-MM" :disabled="!!editingMonth" /></el-form-item><el-form-item label="美元兑人民币平均汇率"><el-input-number v-model="form.usdToCnyAverage" :min="0.00000001" :precision="8" placeholder="请输入美元兑人民币月均汇率" /><p>汇率方向:1 USD = X CNY</p></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="请输入备注(选填)" /></el-form-item></el-form></AppDialog>
@@ -54,11 +54,16 @@ async function exportCsv(){exporting.value=true;try{const {page:_,size:__,...q}=
watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions()])}) watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions()])})
</script> </script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="海外仓报价列表"> <template><AdminPage class="admin-density"><ListPageLayout aria-label="海外仓报价列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外仓报价筛选" preference-key="overseas-quotes-query" :loading="loading" @search="search" /></template>
<template #state><LoadErrorState v-if="error" title="海外仓报价加载失败" :message="error.message" @retry="load" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-quotes-table" row-key="id" empty-text="暂无海外仓报价"> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-quotes-table" row-key="id" empty-text="暂无海外仓报价">
<template #toolbar-start><h1>海外仓报价</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="海外仓报价筛选" preference-key="overseas-quotes-query" :loading="loading" @search="search" /></template>
<template #notices><LoadErrorState v-if="error" title="海外仓报价加载失败" :message="error.message" @retry="load" /></template>
<template #toolbar-actions><el-button v-if="access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template> <template #toolbar-actions><el-button v-if="access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template>
<template #actions="{row}"><div class="density-actions"><el-button data-tone="neutral" class="density-action" @click="openDetail(row as OverseasWarehouseQuote)">详情</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.quoteEdit)" @click="openEdit(row as OverseasWarehouseQuote)">编辑</el-button><el-button class="density-action" v-if="access.can(permission.quoteStatus)" :data-tone="(row as OverseasWarehouseQuote).status==='ACTIVE'?'danger':'success'" @click="toggle(row as OverseasWarehouseQuote)">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'停用':'启用'}}</el-button></div></template> <template #actions="{row}"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" @click="openDetail(row as OverseasWarehouseQuote)">详情</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.quoteEdit)" @click="openEdit(row as OverseasWarehouseQuote)">编辑</el-button><el-button class="density-action" v-if="access.can(permission.quoteStatus)" :data-tone="(row as OverseasWarehouseQuote).status==='ACTIVE'?'danger':'success'" @click="toggle(row as OverseasWarehouseQuote)">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'停用':'启用'}}</el-button></div></template>
<template #status="{row}"><el-tag :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'success':'info'">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'启用':'停用'}}</el-tag></template> <template #status="{row}"><el-tag :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'success':'info'">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'启用':'停用'}}</el-tag></template>
</DataTable></section> </DataTable></section>
<AppDialog class="admin-density-dialog" v-model="detailOpen" title="海外仓报价详情" width="min(980px, calc(100vw - 28px))" :show-confirm="false"><dl v-if="detail" class="detail-grid density-fields"><div v-for="item in [['报价名称',detail.summary.name],['海外仓',detail.summary.warehouseName],['服务商',detail.summary.providerName],['币种',detail.summary.currency],['生效日期',detail.summary.effectiveStart],['失效日期',detail.summary.effectiveEnd],['版本',detail.summary.versionCode],['状态',detail.summary.status]]" :key="item[0]"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl><h3>仓储作业费用</h3><el-table :data="detail?.warehouseFees||[]" border><el-table-column prop="item" label="费用项目"/><el-table-column prop="billingUnit" label="计费单位"/><el-table-column prop="unitPrice" label="单价"/><el-table-column prop="minimumCharge" label="最低收费"/><el-table-column prop="remark" label="备注"/></el-table><h3>中转派送价格</h3><el-table :data="detail?.transitPrices||[]" border><el-table-column prop="fbaWarehouseCode" label="目的FBA仓"/><el-table-column prop="deliveryMethod" label="车型/提货方式"/><el-table-column prop="estimatedMiles" label="预估迈数"/><el-table-column prop="price" label="报价"/><el-table-column prop="minimumCharge" label="最低收费"/></el-table></AppDialog> <AppDialog class="admin-density-dialog" v-model="detailOpen" title="海外仓报价详情" width="min(980px, calc(100vw - 28px))" :show-confirm="false"><dl v-if="detail" class="detail-grid density-fields"><div v-for="item in [['报价名称',detail.summary.name],['海外仓',detail.summary.warehouseName],['服务商',detail.summary.providerName],['币种',detail.summary.currency],['生效日期',detail.summary.effectiveStart],['失效日期',detail.summary.effectiveEnd],['版本',detail.summary.versionCode],['状态',detail.summary.status]]" :key="item[0]"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl><h3>仓储作业费用</h3><el-table :data="detail?.warehouseFees||[]" border><el-table-column prop="item" label="费用项目"/><el-table-column prop="billingUnit" label="计费单位"/><el-table-column prop="unitPrice" label="单价"/><el-table-column prop="minimumCharge" label="最低收费"/><el-table-column prop="remark" label="备注"/></el-table><h3>中转派送价格</h3><el-table :data="detail?.transitPrices||[]" border><el-table-column prop="fbaWarehouseCode" label="目的FBA仓"/><el-table-column prop="deliveryMethod" label="车型/提货方式"/><el-table-column prop="estimatedMiles" label="预估迈数"/><el-table-column prop="price" label="报价"/><el-table-column prop="minimumCharge" label="最低收费"/></el-table></AppDialog>
@@ -24,6 +24,11 @@ function openStatus(row:VehicleDetail){statusRow.value=row;statusTarget.value=ro
async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();saveCsv(await exportVehicles(q),'vehicles.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}} async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();saveCsv(await exportVehicles(q),'vehicles.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}}
watch([page,pageSize],load);onMounted(load) watch([page,pageSize],load);onMounted(load)
</script> </script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="车辆列表"><template #toolbar><div class="query-panel"><div class="status-tabs density-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{active:ownership===tab[1]}]" @click="selectOwnership(tab[1])"><span class="density-tab-icon" aria-hidden="true">{{ tab[1] === 'OWNED' ? '🚚' : '🚛' }}</span>{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template><template #state><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template><section class="surface-panel vehicle-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆"><template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template><template #actions="{row}"><div class="density-actions"><el-button data-tone="neutral" class="density-action" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})">查看</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleEdit)" @click="openEdit(row as VehicleDetail)">编辑</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleStatus)" @click="openStatus(row as VehicleDetail)">状态</el-button></div></template><template #status="{row}"><el-tag>{{vehicleStatusLabels[(row as VehicleDetail).status]}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section> <template><AdminPage class="admin-density"><ListPageLayout aria-label="车辆列表"><section class="vehicle-panel"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆">
<template #toolbar-start><h1>车辆列表</h1></template>
<template #filters><div class="query-panel integrated-query-group"><div class="status-tabs density-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{active:ownership===tab[1]}]" @click="selectOwnership(tab[1])"><span class="density-tab-icon" aria-hidden="true">{{ tab[1] === 'OWNED' ? '🚚' : '🚛' }}</span>{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template>
<template #notices><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template>
<template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template><template #actions="{row}"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})">查看</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleEdit)" @click="openEdit(row as VehicleDetail)">编辑</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleStatus)" @click="openStatus(row as VehicleDetail)">状态</el-button></div></template><template #status="{row}"><el-tag>{{vehicleStatusLabels[(row as VehicleDetail).status]}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(760px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form class="density-form" label-position="top"><div class="form-grid"><el-form-item label="车辆归属"><el-select v-model="form.ownership"><el-option label="自有车辆" value="OWNED"/><el-option label="外派车辆" value="DISPATCHED"/></el-select></el-form-item><el-form-item label="车牌号"><el-input v-model="form.plateNumber" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车型"><el-input v-model="form.model" placeholder="请输入车辆型号" /></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2" placeholder="请输入百公里油耗" /></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0" placeholder="请输入当前里程" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany" placeholder="请输入司机或外派单位" /></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" placeholder="请输入备注(选填)" /></el-form-item></div></el-form></AppDialog><AppDialog class="admin-density-dialog" v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template> <AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(760px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form class="density-form" label-position="top"><div class="form-grid"><el-form-item label="车辆归属"><el-select v-model="form.ownership"><el-option label="自有车辆" value="OWNED"/><el-option label="外派车辆" value="DISPATCHED"/></el-select></el-form-item><el-form-item label="车牌号"><el-input v-model="form.plateNumber" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车型"><el-input v-model="form.model" placeholder="请输入车辆型号" /></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2" placeholder="请输入百公里油耗" /></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0" placeholder="请输入当前里程" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany" placeholder="请输入司机或外派单位" /></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" placeholder="请输入备注(选填)" /></el-form-item></div></el-form></AppDialog><AppDialog class="admin-density-dialog" v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template>
<style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{overflow:hidden;border:1px solid var(--cc-border);border-radius:var(--admin-radius);background:#fff}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-5) var(--admin-space-6)}</style> <style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{min-width:0}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-5) var(--admin-space-6)}</style>
+3 -3
View File
@@ -6,19 +6,19 @@ export const overseasMasterDataRoutes = [
path: 'overseas-master-data/quotes', path: 'overseas-master-data/quotes',
name: 'overseas-master-data-quotes', name: 'overseas-master-data-quotes',
component: () => import('./pages/OverseasQuotesView.vue'), component: () => import('./pages/OverseasQuotesView.vue'),
meta: { title: '海外仓报价', description: '维护海外合作仓仓储作业与中转派送报价', permission: permission.quoteList }, meta: { showPageHeading: false, title: '海外仓报价', description: '维护海外合作仓仓储作业与中转派送报价', permission: permission.quoteList },
}, },
{ {
path: 'overseas-master-data/exchange-rates', path: 'overseas-master-data/exchange-rates',
name: 'overseas-master-data-exchange-rates', name: 'overseas-master-data-exchange-rates',
component: () => import('./pages/ExchangeRatesView.vue'), component: () => import('./pages/ExchangeRatesView.vue'),
meta: { title: '汇率管理', description: '维护美元兑人民币月平均汇率', permission: permission.exchangeRateList }, meta: { showPageHeading: false, title: '汇率管理', description: '维护美元兑人民币月平均汇率', permission: permission.exchangeRateList },
}, },
{ {
path: 'overseas-master-data/vehicles', path: 'overseas-master-data/vehicles',
name: 'overseas-master-data-vehicles', name: 'overseas-master-data-vehicles',
component: () => import('./pages/VehiclesView.vue'), component: () => import('./pages/VehiclesView.vue'),
meta: { title: '车辆列表', description: '查询国内与海外自有、外派车辆档案', permission: permission.vehicleList }, meta: { showPageHeading: false, title: '车辆列表', description: '查询国内与海外自有、外派车辆档案', permission: permission.vehicleList },
}, },
{ {
path: 'overseas-master-data/vehicles/:vehicleId', path: 'overseas-master-data/vehicles/:vehicleId',
@@ -27,7 +27,7 @@ describe('overseas warehouse 1920px layout acceptance', () => {
it('keeps all inbound-order row actions on one line', () => { it('keeps all inbound-order row actions on one line', () => {
const source = page('InboundOrdersView.vue') const source = page('InboundOrdersView.vue')
expect(source).toContain('<template #actions="{ row }"><div class="row-actions density-actions">') expect(source).toContain('<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions">')
expect(source).toContain('.row-actions { min-width: max-content; flex-wrap: nowrap; white-space: nowrap; }') expect(source).toContain('.row-actions { min-width: max-content; flex-wrap: nowrap; white-space: nowrap; }')
expect(source).toContain('.row-actions :deep(.el-button) { flex: 0 0 auto; }') expect(source).toContain('.row-actions :deep(.el-button) { flex: 0 0 auto; }')
expect(readFileSync(resolve(process.cwd(), 'src/styles/adminDensity.css'), 'utf8')).toContain('.density-actions > :is(.el-button, .el-dropdown) { margin-left: 0; }') expect(readFileSync(resolve(process.cwd(), 'src/styles/adminDensity.css'), 'utf8')).toContain('.density-actions > :is(.el-button, .el-dropdown) { margin-left: 0; }')
@@ -159,18 +159,21 @@ onMounted(load)
<template> <template>
<AdminPage class="admin-density"> <AdminPage class="admin-density">
<ListPageLayout aria-label="客户回访列表"> <ListPageLayout aria-label="客户回访列表">
<template #toolbar>
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-customer-visits" row-key="id" empty-text="暂无可回访派送单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>客户回访</h1></template>
<template #filters>
<dl class="visit-summary density-detail-overview" aria-label="回访统计"><div><dt>可回访订单</dt><dd>{{ summary.visitable }}</dd></div><div><dt>待回访</dt><dd>{{ summary.pending }}</dd></div><div><dt>已完成回访</dt><dd>{{ summary.completed }}</dd></div></dl> <dl class="visit-summary density-detail-overview" aria-label="回访统计"><div><dt>可回访订单</dt><dd>{{ summary.visitable }}</dd></div><div><dt>待回访</dt><dd>{{ summary.pending }}</dd></div><div><dt>已完成回访</dt><dd>{{ summary.completed }}</dd></div></dl>
<QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="客户回访筛选" preference-key="overseas-customer-visits-query" :search-on-reset="false" @search="search" @reset="reset" /> <QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="客户回访筛选" preference-key="overseas-customer-visits-query" :search-on-reset="false" @search="search" @reset="reset" />
</template> </template>
<template v-if="error" #state><LoadErrorState title="客户回访列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template> <template #notices><template v-if="error"><LoadErrorState title="客户回访列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
<section v-if="!error" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-customer-visits" row-key="id" empty-text="暂无可回访派送单" @update:page="load()" @update:page-size="page = 1; load()">
<template #actions="{ row }"><el-button v-if="access.can(permission.customerVisitEdit)" class="density-action" data-tone="neutral" size="small" @click="openVisit(row as CustomerVisitRow)">{{ row.status === 'COMPLETED' ? '再次回访' : '回访' }}</el-button></template> <template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="access.can(permission.customerVisitEdit)" class="density-action" data-tone="neutral" size="small" @click="openVisit(row as CustomerVisitRow)">{{ row.status === 'COMPLETED' ? '再次回访' : '回访' }}</el-button></div></template>
<template #status="{ row }"><StatusBadge :status="row.status === 'COMPLETED' ? '已回访' : '待回访'" /></template> <template #status="{ row }"><StatusBadge :status="row.status === 'COMPLETED' ? '已回访' : '待回访'" /></template>
<template #pagination-left>共 {{ total }} 条回访记录</template> <template #pagination-left>共 {{ total }} 条回访记录</template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
</AdminPage> </AdminPage>
File diff suppressed because one or more lines are too long
@@ -4,6 +4,7 @@ import { resolve } from 'node:path'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches' import { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import DispatchesView from './DispatchesView.vue' import DispatchesView from './DispatchesView.vue'
@@ -60,9 +61,9 @@ describe('DispatchesView', () => {
[['edit'], ['查看', '打印BOL', '编辑'], false], [['edit'], ['查看', '打印BOL', '编辑'], false],
[['cancel'], ['查看', '打印BOL', '取消'], false], [['cancel'], ['查看', '打印BOL', '取消'], false],
[['transfer-delivery'], ['查看', '打印BOL', '转派送'], false], [['transfer-delivery'], ['查看', '打印BOL', '转派送'], false],
[['edit', 'transfer-delivery'], ['查看', '打印BOL', '转派送', ''], true], [['edit', 'transfer-delivery'], ['查看', '打印BOL', '转派送', '更多'], true],
[['edit', 'cancel'], ['查看', '打印BOL', '编辑', ''], true], [['edit', 'cancel'], ['查看', '打印BOL', '编辑', '更多'], true],
[['edit', 'cancel', 'transfer-delivery'], ['查看', '打印BOL', '转派送', ''], true], [['edit', 'cancel', 'transfer-delivery'], ['查看', '打印BOL', '转派送', '更多'], true],
])('uses the row action threshold for permissions %j', async (allowed, labels, overflow) => { ])('uses the row action threshold for permissions %j', async (allowed, labels, overflow) => {
canPermission.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:dispatch:' + action)) canPermission.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:dispatch:' + action))
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } }) const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
@@ -72,6 +73,23 @@ describe('DispatchesView', () => {
expect(actions.find('.el-dropdown').exists()).toBe(overflow) expect(actions.find('.el-dropdown').exists()).toBe(overflow)
}) })
it('keeps the integrated header and filters usable through failure and retry', async () => {
vi.mocked(listDispatches).mockRejectedValueOnce(new Error('网络错误'))
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const card = wrapper.get('.data-table--integrated')
expect(card.get('.data-table__toolbar h1').text()).toBe('排车列表')
expect(card.get('.data-table__toolbar button[aria-label="列设置"]').text()).toBe('字段设置')
expect(card.get('.data-table__filters').findComponent(QueryToolbar).exists()).toBe(true)
expect(card.find('.el-table').exists()).toBe(false)
expect(card.find('.data-table__pagination').exists()).toBe(false)
wrapper.findComponent(LoadErrorState).vm.$emit('retry')
await flushPromises()
expect(card.find('.data-table__state').exists()).toBe(false)
expect(card.get('.el-table').text()).toContain('PC-1')
expect(card.get('.data-table__pagination').text()).toContain('共 1 条排车记录')
})
it('loads immediately and sends only supported page filters', async () => { it('loads immediately and sends only supported page filters', async () => {
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } }) const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises() await flushPromises()
@@ -92,6 +110,13 @@ describe('DispatchesView', () => {
expect(wrapper.text()).toContain('待发车') expect(wrapper.text()).toContain('待发车')
expect(wrapper.text()).toContain('FBA-1 · 已确认') expect(wrapper.text()).toContain('FBA-1 · 已确认')
expect(wrapper.text()).toContain('已预约') expect(wrapper.text()).toContain('已预约')
expect(wrapper.get('.data-table').classes()).not.toContain('admin-text-actions')
expect(wrapper.get('.query-toolbar').classes()).not.toContain('admin-text-actions')
expect(wrapper.get('.data-table__toolbar button[aria-label="列设置"]').find('svg').exists()).toBe(true)
expect(wrapper.get('.query-toolbar__actions .el-button--primary').text()).toBe('查询')
expect(wrapper.get('button[aria-label="上一页"]').find('svg').exists()).toBe(true)
expect(wrapper.findAll('.query-toolbar__actions button svg')).toHaveLength(3)
expect(wrapper.get('.el-table__body-wrapper .row-actions').classes()).toContain('admin-text-actions')
}) })
it('keeps long dispatch identifiers as strings', async () => { it('keeps long dispatch identifiers as strings', async () => {
@@ -561,13 +561,14 @@ onMounted(load)
<template> <template>
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表"> <AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" /> <OperationErrorToast :details="mutationError" />
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></template> <DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template> <template #toolbar-start><h1>排车列表</h1></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()"> <template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #actions="{ row }"><RowActions :actions="rowActions(row as DispatchListItem)" :more-label="`更多操作 · ${(row as DispatchListItem).dispatchNo || '当前记录'}`" @action="handleRowAction($event, row as DispatchListItem)" /></template> <template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListItem)" :more-label="`更多操作 · ${(row as DispatchListItem).dispatchNo || '当前记录'}`" @action="handleRowAction($event, row as DispatchListItem)" /></template>
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template> <template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #pagination-left>共 {{ total }} 条排车记录</template> <template #pagination-left>共 {{ total }} 条排车记录</template>
</DataTable></section> </DataTable>
</ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form class="density-form" label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog> </ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form class="density-form" label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog>
<AppDialog class="admin-density-dialog" v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态;未生成出库单前可移除托盘。`" type="info" :closable="false" /><el-form class="density-form" label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" readonly placeholder="按已排托盘自动汇总 FBA 仓" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form><h3>已排车托盘</h3><el-table :data="editItems" border max-height="280" empty-text="暂无托盘"><el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId)" @click="removePallet(row)">移除</el-button></template></el-table-column><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /></el-table></AppDialog> <AppDialog class="admin-density-dialog" v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态;未生成出库单前可移除托盘。`" type="info" :closable="false" /><el-form class="density-form" label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" readonly placeholder="按已排托盘自动汇总 FBA 仓" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form><h3>已排车托盘</h3><el-table :data="editItems" border max-height="280" empty-text="暂无托盘"><el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId)" @click="removePallet(row)">移除</el-button></template></el-table-column><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /></el-table></AppDialog>
</template> </template>
@@ -78,11 +78,16 @@ onMounted(load)
<template> <template>
<AdminPage class="admin-density"><ListPageLayout aria-label="异常订单列表"> <AdminPage class="admin-density"><ListPageLayout aria-label="异常订单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="异常订单筛选" preference-key="overseas-exception-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="error" #state><LoadErrorState title="异常订单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-exception-orders" row-key="id" empty-text="暂无异常订单" @update:page="load()" @update:page-size="page = 1; load()"> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-exception-orders" row-key="id" empty-text="暂无异常订单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>异常订单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="异常订单筛选" preference-key="overseas-exception-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="error"><LoadErrorState title="异常订单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="access.can(permission.exceptionOrderCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增异常订单</el-button></template> <template #toolbar-actions><el-button v-if="access.can(permission.exceptionOrderCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增异常订单</el-button></template>
<template #actions="{ row }"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as ExceptionOrderRow)">查看</el-button></template> <template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as ExceptionOrderRow)">查看</el-button></div></template>
<template #related="{ row }"><div class="related-orders"><span v-if="row.lastMileOrderNo" class="related-order-reference">尾程 <OrderReferenceText :value="String(row.lastMileOrderNo)" /></span><span v-if="row.deliveryNo">派送 {{ row.deliveryNo }}</span><span v-if="row.inboundCode">入仓 {{ row.inboundCode }}</span><span v-if="row.outboundNo">出库 {{ row.outboundNo }}</span></div></template> <template #related="{ row }"><div class="related-orders"><span v-if="row.lastMileOrderNo" class="related-order-reference">尾程 <OrderReferenceText :value="String(row.lastMileOrderNo)" /></span><span v-if="row.deliveryNo">派送 {{ row.deliveryNo }}</span><span v-if="row.inboundCode">入仓 {{ row.inboundCode }}</span><span v-if="row.outboundNo">出库 {{ row.outboundNo }}</span></div></template>
<template #handlerName="{ row }">{{ displayHandler(row.handlerName) }}</template> <template #handlerName="{ row }">{{ displayHandler(row.handlerName) }}</template>
<template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status)" /></template><template #pagination-left>共 {{ total }} 条异常订单</template> <template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status)" /></template><template #pagination-left>共 {{ total }} 条异常订单</template>
@@ -191,11 +191,16 @@ onMounted(() => { void load() })
<template> <template>
<AdminPage class="admin-density"><OperationErrorToast :details="mutationError" /><ListPageLayout aria-label="海外仓入库单列表"> <AdminPage class="admin-density"><OperationErrorToast :details="mutationError" /><ListPageLayout aria-label="海外仓入库单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="入库单筛选" preference-key="overseas-inbound-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="入库单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inbound-orders" row-key="lineId" empty-text="暂无入库单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()"> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inbound-orders" row-key="lineId" empty-text="暂无入库单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>入库单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="入库单筛选" preference-key="overseas-inbound-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="入库单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="access.can(permission.inboundOrderSmartDispatch)" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template> <template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="access.can(permission.inboundOrderSmartDispatch)" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" @click="openPlanEdit(row)">编辑</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button></div></template> <template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" @click="openPlanEdit(row)">编辑</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button></div></template>
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template> <template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section> </DataTable></section>
</ListPageLayout> </ListPageLayout>
@@ -42,10 +42,16 @@ function search(model: QueryModel) { page.value = 1; void load(model) }; functio
async function exportLedger() { exportLoading.value = true; try { const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; saveCsv(await downloadInventoryLedger({ customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }) as Blob, '进销存记录.csv') } catch { ElMessage.error('进销存导出失败,请重试') } finally { exportLoading.value = false } } async function exportLedger() { exportLoading.value = true; try { const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; saveCsv(await downloadInventoryLedger({ customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }) as Blob, '进销存记录.csv') } catch { ElMessage.error('进销存导出失败,请重试') } finally { exportLoading.value = false } }
</script> </script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="进销存记录列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" :search-on-reset="false" @search="search" @reset="reset" /></template> <template><AdminPage class="admin-density"><ListPageLayout aria-label="进销存记录列表">
<template v-if="loadError" #state><LoadErrorState title="进销存记录加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template v-else #state><p class="read-only-boundary">本页面仅用于查询库存流水,不提供出库单跳转或库存变更操作。</p></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory-transactions" row-key="id" empty-text="暂无进销存记录" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport)" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button class="density-back" link @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" :disabled="(row as InventoryTransactionListItem).inboundCode === '—'" @click="openRelationship(row as InventoryTransactionListItem)">关联数据</el-button></div></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory-transactions" row-key="id" empty-text="暂无进销存记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>进销存记录</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="进销存记录加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template v-else><p class="read-only-boundary">本页面仅用于查询库存流水,不提供出库单跳转或库存变更操作。</p></template></template>
<template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport)" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button class="density-back" link @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" :disabled="(row as InventoryTransactionListItem).inboundCode === '—'" @click="openRelationship(row as InventoryTransactionListItem)">关联数据</el-button></div></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
<InboundCodeRelationshipDialog dense v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" /> <InboundCodeRelationshipDialog dense v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
</ListPageLayout></AdminPage></template> </ListPageLayout></AdminPage></template>
<style scoped>.read-only-boundary{margin:var(--admin-space-3) 2px 0;color:var(--cc-muted);font-size:var(--admin-font-caption);line-height:1.5}</style> <style scoped>.read-only-boundary{margin:var(--admin-space-3) 2px 0;color:var(--cc-muted);font-size:var(--admin-font-caption);line-height:1.5}</style>
@@ -88,12 +88,17 @@ onMounted(() => { void loadWarehouses(); void load() })
<template> <template>
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓库存管理"> <AdminPage class="admin-density"><ListPageLayout aria-label="海外仓库存管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading || contextLoading" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="search" @reset="page = 1; rows = []; total = 0; summary = null" /></template>
<template #metrics><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}</span><strong>{{ metric[1] }}</strong></div></section></template>
<template v-if="loadError" #state><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory" row-key="id" empty-text="暂无库存记录" @update:page="load()" @update:page-size="page = 1; load()"> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory" row-key="id" empty-text="暂无库存记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>库存管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading || contextLoading" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="search" @reset="page = 1; rows = []; total = 0; summary = null" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #before-results><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}</span><strong>{{ metric[1] }}</strong></div></section></template>
<template #toolbar-actions><el-button v-if="access.can(overseasWarehousePermissions.inventoryExport)" plain :loading="exportLoading" @click="exportBalances"><Download :size="16" />导出库存</el-button><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template> <template #toolbar-actions><el-button v-if="access.can(overseasWarehousePermissions.inventoryExport)" plain :loading="exportLoading" @click="exportBalances"><Download :size="16" />导出库存</el-button><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button></div></template> <template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button></div></template>
<template #pagination-left>共 {{ total }} 条库存记录</template> <template #pagination-left>共 {{ total }} 条库存记录</template>
</DataTable></section> </DataTable></section>
</ListPageLayout></AdminPage> </ListPageLayout></AdminPage>
@@ -906,8 +906,14 @@ onMounted(() => { void load() })
<template> <template>
<AdminPage class="admin-density"><OperationErrorToast :details="actionError" /><ListPageLayout aria-label="尾程订单列表"> <AdminPage class="admin-density"><OperationErrorToast :details="actionError" /><ListPageLayout aria-label="尾程订单列表">
<template #toolbar>
<section class="surface-panel last-mile-toolbar" aria-label="尾程订单状态与筛选">
<DataTable appearance="integrated" :stripe="false" :results-visible="!loadError" :column-settings-mode="statusTab === '柜子' ? 'hide' : 'show'" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="statusTab === '柜子' ? undefined : total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>尾程订单</h1></template>
<template #filters>
<section class="last-mile-toolbar integrated-query-group" aria-label="尾程订单状态与筛选">
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab"> <el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab">
<el-radio-button value="全部">全部</el-radio-button> <el-radio-button value="全部">全部</el-radio-button>
<el-radio-button value="待分配">待分配</el-radio-button> <el-radio-button value="待分配">待分配</el-radio-button>
@@ -917,15 +923,14 @@ onMounted(() => { void load() })
<QueryToolbar v-model="filters" :fields="fields" :available-fields="allFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset" /> <QueryToolbar v-model="filters" :fields="fields" :available-fields="allFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset" />
</section> </section>
</template> </template>
<template v-if="loadError" #state> <template #notices><template v-if="loadError">
<LoadErrorState title="尾程订单加载失败" :message="loadError.message" :loading="loading" @retry="load"> <LoadErrorState title="尾程订单加载失败" :message="loadError.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="loadError" /></template> <template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState> </LoadErrorState>
</template> </template><LoadErrorState v-if="actionError" title="尾程订单操作失败" :message="actionError.message" compact>
<LoadErrorState v-if="actionError" title="尾程订单操作失败" :message="actionError.message" compact>
<template #details><ApiErrorMeta :details="actionError" /></template> <template #details><ApiErrorMeta :details="actionError" /></template>
</LoadErrorState> </LoadErrorState></template>
<section v-if="!loadError && statusTab === '柜子'" class="surface-panel container-workbench" :aria-busy="loading"> <template v-if="statusTab === '柜子'" #results><section class="container-workbench" :aria-busy="loading">
<header class="container-workbench__header"> <header class="container-workbench__header">
<div><strong>货柜工作台</strong><span>按柜查看和修改海外接柜仓;整柜计划齐全后自动生成托盘码</span></div> <div><strong>货柜工作台</strong><span>按柜查看和修改海外接柜仓;整柜计划齐全后自动生成托盘码</span></div>
<el-tag type="warning" effect="plain">{{ containerTotal }} 个货柜</el-tag> <el-tag type="warning" effect="plain">{{ containerTotal }} 个货柜</el-tag>
@@ -960,16 +965,16 @@ onMounted(() => { void load() })
<span>共 {{ containerTotal }} 个货柜</span> <span>共 {{ containerTotal }} 个货柜</span>
<el-pagination :current-page="page" :page-size="pageSize" :page-sizes="[10, 20, 50]" :total="containerTotal" layout="sizes, prev, pager, next" background @current-change="changeContainerPage" @size-change="changeContainerPageSize" /> <el-pagination :current-page="page" :page-size="pageSize" :page-sizes="[10, 20, 50]" :total="containerTotal" layout="sizes, prev, pager, next" background @current-change="changeContainerPage" @size-change="changeContainerPageSize" />
</div> </div>
</section> </section></template>
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || hasSelectedRejectedReturn() || hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template> <template v-if="statusTab !== '柜子'" #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || hasSelectedRejectedReturn() || hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #relabelOrderNo="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.relabelNo }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelOrderNo }}</span></template> <template #relabelOrderNo="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.relabelNo }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelOrderNo }}</span></template>
<template #relabelStatus="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.statusLabel }} · {{ application.quantity }} 件</div><span v-if="!row.relabelApplications?.length">{{ row.relabelStatus }}</span></template> <template #relabelStatus="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.statusLabel }} · {{ application.quantity }} 件</div><span v-if="!row.relabelApplications?.length">{{ row.relabelStatus }}</span></template>
<template #actions="{ row }"><RowActions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template> <template #actions="{ row }"><RowActions text-actions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template> <template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ packageDifferenceText(row.cartonDifference) }}</span></template> <template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ packageDifferenceText(row.cartonDifference) }}</span></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template> <template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section> </DataTable>
</ListPageLayout></AdminPage> </ListPageLayout></AdminPage>
<WarehouseTransferReviewDialog v-if="transferReviewTarget" v-model="transferReviewOpen" :customer-id="transferReviewTarget.customerId" :customer-name="transferReviewTarget.owner" :transfer-no="transferReviewTarget.orderNo" @reviewed="load()" /> <WarehouseTransferReviewDialog v-if="transferReviewTarget" v-model="transferReviewOpen" :customer-id="transferReviewTarget.customerId" :customer-name="transferReviewTarget.owner" :transfer-no="transferReviewTarget.orderNo" @reviewed="load()" />
<AppDialog class="admin-density-dialog" v-model="transferOpen" title="转仓" confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer"> <AppDialog class="admin-density-dialog" v-model="transferOpen" title="转仓" confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer">
@@ -1106,7 +1111,7 @@ onMounted(() => { void load() })
<style scoped> <style scoped>
.relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; } .relabel-suborder { min-height: 30px; line-height: 30px; white-space: nowrap; }
.last-mile-toolbar { padding: var(--admin-space-5) var(--admin-space-5) 0; } .last-mile-toolbar { min-width: 0; }
.row-actions { white-space: nowrap; } .row-actions { white-space: nowrap; }
.command-form { margin-top: var(--admin-space-5); } .command-form { margin-top: var(--admin-space-5); }
.command-form__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); } .command-form__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
@@ -1117,7 +1122,7 @@ onMounted(() => { void load() })
.manual-node-table__head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface); color: var(--cc-muted); font-size: var(--admin-font-body); font-weight: 600; } .manual-node-table__head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface); color: var(--cc-muted); font-size: var(--admin-font-body); font-weight: 600; }
.manual-node-table__row { border-top: 1px solid var(--cc-border); } .manual-node-table__row { border-top: 1px solid var(--cc-border); }
.manual-node-table__row :deep(.el-date-editor) { width: 100%; } .manual-node-table__row :deep(.el-date-editor) { width: 100%; }
.last-mile-toolbar :deep(.query-toolbar) { margin-top: var(--admin-space-5); padding: var(--admin-space-5) 0 var(--admin-space-5); border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
.container-convert-hint { margin: 0 0 var(--admin-space-5); color: var(--cc-muted); line-height: 1.6; } .container-convert-hint { margin: 0 0 var(--admin-space-5); color: var(--cc-muted); line-height: 1.6; }
.container-convert-alert { margin-top: var(--admin-space-5); } .container-convert-alert { margin-top: var(--admin-space-5); }
.container-preview { margin-top: var(--admin-space-5); } .container-preview { margin-top: var(--admin-space-5); }
@@ -46,6 +46,11 @@ async function remove(row: OverseasLocationDto) { if (!canDelete(row)) return; t
onMounted(() => { void loadWarehouses() }) onMounted(() => { void loadWarehouses() })
</script> </script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="海外仓库位管理"><template #toolbar><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外库位筛选" preference-key="overseas-locations-query" @search="search" @reset="page = 1; load()" /></template><template #state><LoadErrorState v-if="loadError" title="海外库位加载失败" :message="loadError.message" :loading="loading" @retry="load" /></template><section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" empty-text="暂无库位" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button v-if="access.can(permission.locationCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增库位</el-button></template><template #actions="{ row }"><div class="density-actions"><el-button v-if="canEdit(row as OverseasLocationDto)" size="small" class="density-action" @click="openEdit(row as OverseasLocationDto)">编辑</el-button><el-button v-if="canStatus(row as OverseasLocationDto)" size="small" class="density-action" :data-tone="(row as OverseasLocationDto).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as OverseasLocationDto)">{{ (row as OverseasLocationDto).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="canDelete(row as OverseasLocationDto)" size="small" class="density-action" data-tone="danger" @click="remove(row as OverseasLocationDto)">删除</el-button></div></template><template #warehouse="{ row }">{{ (row as OverseasLocationDto).warehouseName || warehouseNameById.get(String((row as OverseasLocationDto).warehouseId)) || '—' }}</template><template #locationType="{ row }"><el-tag :type="isA0(row as OverseasLocationDto) ? 'warning' : 'info'">{{ typeLabels[(row as OverseasLocationDto).locationType] || '普通库位' }}</el-tag></template><template #status="{ row }"><el-tag>{{ statusLabels[(row as OverseasLocationDto).status] || '—' }}</el-tag></template><template #pagination-left>共 {{ total }} 个库位</template></DataTable></section></ListPageLayout><AppDialog v-model="editorOpen" :title="editing ? '编辑海外库位' : '新增海外库位'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" :show-confirm="false"><el-form label-position="top" :disabled="saving"><div class="editor-grid"><el-form-item label="海外仓" required><el-select v-model="form.warehouseId" :disabled="Boolean(editing)" placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :value="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" /></el-select></el-form-item><el-form-item label="库位编码" required><el-input v-model="form.locationCode" maxlength="50" placeholder="请输入库位编码" /></el-form-item><el-form-item label="库位名称"><el-input v-model="form.locationName" maxlength="100" placeholder="请输入库位名称(选填)" /></el-form-item><el-form-item label="最大承重(kg)" required><el-input-number v-model="form.maxWeightKg" :min="0" :precision="3" controls-position="right" placeholder="请输入最大承重" /></el-form-item></div><el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" placeholder="请输入备注(选填)" /></el-form-item></el-form><template #footer><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存库位</el-button></template></AppDialog></AdminPage></template> <template><AdminPage class="admin-density"><ListPageLayout aria-label="海外仓库位管理"><section><DataTable preference-key="overseas-warehouse-locationsview" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" empty-text="暂无库位" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>库位管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外库位筛选" preference-key="overseas-locations-query" @search="search" @reset="page = 1; load()" /></template>
<template #notices><LoadErrorState v-if="loadError" title="海外库位加载失败" :message="loadError.message" :loading="loading" @retry="load" /></template>
<template #toolbar-actions><el-button v-if="access.can(permission.locationCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增库位</el-button></template><template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button v-if="canEdit(row as OverseasLocationDto)" size="small" class="density-action" @click="openEdit(row as OverseasLocationDto)">编辑</el-button><el-button v-if="canStatus(row as OverseasLocationDto)" size="small" class="density-action" :data-tone="(row as OverseasLocationDto).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as OverseasLocationDto)">{{ (row as OverseasLocationDto).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="canDelete(row as OverseasLocationDto)" size="small" class="density-action" data-tone="danger" @click="remove(row as OverseasLocationDto)">删除</el-button></div></template><template #warehouse="{ row }">{{ (row as OverseasLocationDto).warehouseName || warehouseNameById.get(String((row as OverseasLocationDto).warehouseId)) || '—' }}</template><template #locationType="{ row }"><el-tag :type="isA0(row as OverseasLocationDto) ? 'warning' : 'info'">{{ typeLabels[(row as OverseasLocationDto).locationType] || '普通库位' }}</el-tag></template><template #status="{ row }"><el-tag>{{ statusLabels[(row as OverseasLocationDto).status] || '—' }}</el-tag></template><template #pagination-left>共 {{ total }} 个库位</template></DataTable></section></ListPageLayout><AppDialog v-model="editorOpen" :title="editing ? '编辑海外库位' : '新增海外库位'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" :show-confirm="false"><el-form label-position="top" :disabled="saving"><div class="editor-grid"><el-form-item label="海外仓" required><el-select v-model="form.warehouseId" :disabled="Boolean(editing)" placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :value="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" /></el-select></el-form-item><el-form-item label="库位编码" required><el-input v-model="form.locationCode" maxlength="50" placeholder="请输入库位编码" /></el-form-item><el-form-item label="库位名称"><el-input v-model="form.locationName" maxlength="100" placeholder="请输入库位名称(选填)" /></el-form-item><el-form-item label="最大承重(kg)" required><el-input-number v-model="form.maxWeightKg" :min="0" :precision="3" controls-position="right" placeholder="请输入最大承重" /></el-form-item></div><el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" placeholder="请输入备注(选填)" /></el-form-item></el-form><template #footer><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存库位</el-button></template></AppDialog></AdminPage></template>
<style scoped>.editor-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 14px}.editor-grid :deep(.el-select),.editor-grid :deep(.el-input-number){width:100%}@media(max-width:720px){.editor-grid{grid-template-columns:1fr}}</style> <style scoped>.editor-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 14px}.editor-grid :deep(.el-select),.editor-grid :deep(.el-input-number){width:100%}@media(max-width:720px){.editor-grid{grid-template-columns:1fr}}</style>
@@ -0,0 +1,71 @@
import { computed } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DataTable from '@/shared/components/DataTable.vue'
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
import { listMojiaSkuSync } from '../api/mojiaSkuSync'
import MojiaSkuSyncView from './MojiaSkuSyncView.vue'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/mojiaSkuSync', () => ({
getMojiaSkuSyncConfig: vi.fn().mockResolvedValue({ autoSyncEnabled: false }),
listMojiaSkuSync: vi.fn(), retryMojiaSkuSync: vi.fn(), updateMojiaSkuSync: vi.fn(),
submitLingxingVerificationCode: vi.fn(), testLingxingConnection: vi.fn(),
}))
let wrapper: VueWrapper | undefined
async function mountView() {
wrapper = mount(MojiaSkuSyncView, {
attachTo: document.body,
global: { plugins: [ElementPlus], provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'mojia-test') } },
})
await flushPromises()
return wrapper
}
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
vi.mocked(listMojiaSkuSync).mockResolvedValue({
records: [{ id: 'record-1', measurementKind: 'SKU', boxCode: '', skuCode: 'SKU-TEST', lengthCm: 20, widthCm: 10, heightCm: 10, weightKg: 1, revision: 1 }],
page: 1, size: 20, total: 70, pages: 4,
})
})
afterEach(() => { wrapper?.unmount(); wrapper = undefined; document.body.innerHTML = '' })
describe('MojiaSkuSyncView shared pagination', () => {
it('preserves server paging and resets to page one when querying or changing page size', async () => {
const view = await mountView()
expect(view.get('h1').text()).toBe('称重机器')
expect(view.getComponent(DataTable).props('pageSizes')).toEqual([10, 20, 30, 40, 50, 100])
view.getComponent(DataTable).vm.$emit('update:page', 2)
await flushPromises()
expect(listMojiaSkuSync).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 20 }))
await view.get('input[placeholder="搜索箱码或 SKU"]').setValue('SKU-QUERY')
await view.get('.sync-filters .el-button--primary').trigger('click')
await flushPromises()
expect(listMojiaSkuSync).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, code: 'SKU-QUERY' }))
view.getComponent(DataTable).vm.$emit('update:page', 2)
await flushPromises()
vi.mocked(listMojiaSkuSync).mockClear()
view.getComponent(DataTable).vm.$emit('update:pageSize', 50)
await flushPromises()
expect(listMojiaSkuSync).toHaveBeenCalledTimes(1)
expect(listMojiaSkuSync).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 50, code: 'SKU-QUERY' }))
})
it('restores resized columns after remount while preserving query buttons and operation permissions', async () => {
const view = await mountView()
expect(view.find('button[aria-label="列设置"]').exists()).toBe(true)
expect(view.get('.sync-filters .el-button--primary').classes()).not.toContain('density-action')
expect(view.getComponent(DataTable).props('columns')[0]).toMatchObject({ key: 'actions', fixed: 'left' })
view.getComponent({ name: 'ElTable' }).vm.$emit('header-dragend', 260, 180, { columnKey: 'sourceCode' })
await flushPromises()
expect(JSON.parse(localStorage.getItem(preferenceStorageKey('table', 'mojia-test', 'mojia-sku-sync-table'))!).widths.sourceCode).toBe(260)
view.unmount()
wrapper = undefined
const restored = await mountView()
const column = restored.findAllComponents({ name: 'ElTableColumn' }).find(item => item.props('columnKey') === 'sourceCode')!
expect(column.props('width')).toBe(260)
expect(restored.get('.el-table__body .admin-text-actions').findAll('button').map(button => button.text())).toEqual(['编辑', '人工推送'])
})
})
@@ -2,7 +2,7 @@
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Cable, Edit3, RefreshCw, RotateCw } from '@lucide/vue' import { Cable, RefreshCw } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue'
@@ -188,18 +188,10 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
<template> <template>
<AdminPage class="admin-density mojia-sku-sync-page"> <AdminPage class="admin-density mojia-sku-sync-page">
<ListPageLayout aria-label="称重机器"> <ListPageLayout aria-label="称重机器">
<section class="surface-panel table-panel" :aria-busy="loading"> <DataTable v-model:page="page" v-model:page-size="size" :total="total" :page-sizes="[10, 20, 30, 40, 50, 100]" @update:page="load" @update:page-size="search" appearance="integrated" :stripe="false" column-settings-mode="show" preference-key="mojia-sku-sync-table" :data="rows" :columns="columns" :loading="loading" :aria-busy="loading" row-key="id" border empty-text="暂无墨家机器上传记录">
<el-alert v-if="error" type="error" :title="error" show-icon :closable="false" /> <template #toolbar-start><h1>称重机器</h1></template>
<el-alert <template #filters>
:type="autoSyncEnabled ? 'warning' : 'info'" <div class="sync-filters query-toolbar">
:title="autoSyncEnabled ? '领星自动同步已开启' : '领星自动同步已关闭,仅支持人工推送'"
description="开关由 Nacos 配置 firstleg.lingxing.auto-sync-enabled 控制。"
show-icon
:closable="false"
/>
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="id" border stripe empty-text="暂无墨家机器上传记录">
<template #toolbar-start>
<div class="sync-filters">
<el-input v-model.trim="form.code" clearable placeholder="搜索箱码或 SKU" @keyup.enter="search" /> <el-input v-model.trim="form.code" clearable placeholder="搜索箱码或 SKU" @keyup.enter="search" />
<el-select v-model="form.measurementKind" clearable placeholder="上传类型" style="width: 130px"><el-option label="箱码" value="BOX_LABEL" /><el-option label="SKU" value="SKU" /></el-select> <el-select v-model="form.measurementKind" clearable placeholder="上传类型" style="width: 130px"><el-option label="箱码" value="BOX_LABEL" /><el-option label="SKU" value="SKU" /></el-select>
<el-select v-model="form.syncStatus" clearable placeholder="同步状态" style="width: 140px"> <el-select v-model="form.syncStatus" clearable placeholder="同步状态" style="width: 140px">
@@ -210,11 +202,20 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
<el-button :loading="testingConnection" @click="testConnection"><Cable :size="16" />测试连接</el-button> <el-button :loading="testingConnection" @click="testConnection"><Cable :size="16" />测试连接</el-button>
</div> </div>
</template> </template>
<template #notices><el-alert v-if="error" type="error" :title="error" show-icon :closable="false" /><el-alert
:type="autoSyncEnabled ? 'warning' : 'info'"
:title="autoSyncEnabled ? '领星自动同步已开启' : '领星自动同步已关闭,仅支持人工推送'"
description="开关由 Nacos 配置 firstleg.lingxing.auto-sync-enabled 控制。"
show-icon
:closable="false"
/></template>
<template #toolbar-actions><el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button></template> <template #toolbar-actions><el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button></template>
<template #actions="{ row }"> <template #actions="{ row }">
<div class="density-actions"> <div class="density-actions row-actions admin-text-actions">
<el-button v-if="canEdit" class="density-action" @click="openEdit(asRecord(row))"><Edit3 :size="15" />编辑</el-button> <el-button v-if="canEdit" class="density-action" @click="openEdit(asRecord(row))">编辑</el-button>
<el-button v-if="canRetry && row.measurementKind === 'SKU' && row.syncStatus !== 'SUCCESS'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="retry(asRecord(row))"><RotateCw :size="15" />人工推送</el-button> <el-button v-if="canRetry && row.measurementKind === 'SKU' && row.syncStatus !== 'SUCCESS'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="retry(asRecord(row))">人工推送</el-button>
</div> </div>
</template> </template>
<template #sourceCode="{ row }">{{ row.measurementKind === 'SKU' ? row.skuCode : row.boxCode }}</template> <template #sourceCode="{ row }">{{ row.measurementKind === 'SKU' ? row.skuCode : row.boxCode }}</template>
@@ -226,8 +227,6 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
<template #measuredAt="{ row }">{{ formatTime(row.measuredAt) }}</template> <template #measuredAt="{ row }">{{ formatTime(row.measuredAt) }}</template>
<template #failure="{ row }">{{ row.syncFailureReason || '—' }}</template> <template #failure="{ row }">{{ row.syncFailureReason || '—' }}</template>
</DataTable> </DataTable>
<div class="sync-pagination"><el-pagination v-model:current-page="page" v-model:page-size="size" :total="total" background layout="total, sizes, prev, pager, next" @current-change="load" @size-change="search" /></div>
</section>
</ListPageLayout> </ListPageLayout>
<AppDialog v-model="editOpen" class="admin-density-dialog" title="编辑 SKU 测量修正版" width="min(620px, calc(100vw - 28px))" confirm-text="保存并重新同步" :confirm-loading="Boolean(actionId)" @confirm="saveEdit"> <AppDialog v-model="editOpen" class="admin-density-dialog" title="编辑 SKU 测量修正版" width="min(620px, calc(100vw - 28px))" confirm-text="保存并重新同步" :confirm-loading="Boolean(actionId)" @confirm="saveEdit">
<el-form label-position="top" class="sync-edit-form"> <el-form label-position="top" class="sync-edit-form">
@@ -196,11 +196,16 @@ onMounted(load)
<template> <template>
<AdminPage class="admin-density"><ListPageLayout aria-label="出库单"> <AdminPage class="admin-density"><ListPageLayout aria-label="出库单">
<OperationErrorToast :details="mutationError" /> <OperationErrorToast :details="mutationError" />
<template #toolbar><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-outbounds" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()"> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-outbounds" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>出库单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="error"><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />新建出库单</el-button></template> <template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />新建出库单</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as OutboundRow)">详情</el-button><el-button class="density-action" data-tone="danger" v-if="canCancel(row as OutboundRow)" size="small" @click="cancel(row as OutboundRow)">取消</el-button></div></template> <template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as OutboundRow)">详情</el-button><el-button class="density-action" data-tone="danger" v-if="canCancel(row as OutboundRow)" size="small" @click="cancel(row as OutboundRow)">取消</el-button></div></template>
<template #outboundType="{ row }">{{ outboundTypeLabel(row.outboundType) }}</template> <template #outboundType="{ row }">{{ outboundTypeLabel(row.outboundType) }}</template>
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : row.status === 'WAIT_OUTBOUND' ? '待出库' : row.status === 'PICKING' ? '拣货中' : row.status === 'WAIT_PICK' ? '待拣货' : '未知状态'" /></template> <template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : row.status === 'WAIT_OUTBOUND' ? '待出库' : row.status === 'PICKING' ? '拣货中' : row.status === 'WAIT_PICK' ? '待拣货' : '未知状态'" /></template>
</DataTable></section> </DataTable></section>
@@ -146,11 +146,16 @@ onMounted(initialize)
<AdminPage class="admin-density"><OperationErrorToast :details="mutationError" /> <AdminPage class="admin-density"><OperationErrorToast :details="mutationError" />
<section class="summary-grid" aria-label="海外托盘数量概览"><article><span>托盘总数</span><strong>{{ summary.totalCount }}</strong></article><article><span>闲置托盘</span><strong class="idle">{{ summary.idleCount }}</strong></article><article><span>占用托盘</span><strong class="occupied">{{ summary.occupiedCount }}</strong></article><article><span>已报废</span><strong>{{ summary.scrappedCount }}</strong></article></section> <section class="summary-grid" aria-label="海外托盘数量概览"><article><span>托盘总数</span><strong>{{ summary.totalCount }}</strong></article><article><span>闲置托盘</span><strong class="idle">{{ summary.idleCount }}</strong></article><article><span>占用托盘</span><strong class="occupied">{{ summary.occupiedCount }}</strong></article><article><span>已报废</span><strong>{{ summary.scrappedCount }}</strong></article></section>
<ListPageLayout aria-label="托盘管理列表"> <ListPageLayout aria-label="托盘管理列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading || contextLoading" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="search" @reset="resetSearch" /></template>
<template v-if="loadError" #state><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-pallets" row-key="id" empty-text="暂无托盘" @update:page="load()" @update:page-size="page = 1; load()"> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-pallets" row-key="id" empty-text="暂无托盘" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>托盘管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading || contextLoading" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="search" @reset="resetSearch" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template> <template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button v-if="access.can(permission.palletList)" class="density-action" data-tone="neutral" size="small" :loading="printingId === row.id" :disabled="Boolean(printingId) && printingId !== row.id" @click="printPallet(row)">打印</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.palletEdit)" size="small" @click="openEdit(row)">编辑</el-button></div></template> <template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button v-if="access.can(permission.palletList)" class="density-action" data-tone="neutral" size="small" :loading="printingId === row.id" :disabled="Boolean(printingId) && printingId !== row.id" @click="printPallet(row)">打印</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.palletEdit)" size="small" @click="openEdit(row)">编辑</el-button></div></template>
<template #pagination-left>共 {{ total }} 条托盘</template> <template #pagination-left>共 {{ total }} 条托盘</template>
</DataTable></section> </DataTable></section>
</ListPageLayout> </ListPageLayout>
@@ -190,11 +190,16 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
<template> <template>
<AdminPage class="admin-density"><ListPageLayout aria-label="换标单列表"> <AdminPage class="admin-density"><ListPageLayout aria-label="换标单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="换标单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" empty-text="暂无换标单" @update:page="load()" @update:page-size="page = 1; load()"> <section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" empty-text="暂无换标单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>换标单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="换标单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template> <template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="warning" v-if="access.can(permission.relabelOrderStatus) && canAudit(row)" size="small" @click="openForm('edit', row)">审核</el-button></div></template> <template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="warning" v-if="access.can(permission.relabelOrderStatus) && canAudit(row)" size="small" @click="openForm('edit', row)">审核</el-button></div></template>
<template #pagination-left>共 {{ total }} 条换标单</template> <template #pagination-left>共 {{ total }} 条换标单</template>
</DataTable></section> </DataTable></section>
</ListPageLayout> </ListPageLayout>
@@ -0,0 +1,92 @@
import { computed } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus'
import DataTable from '@/shared/components/DataTable.vue'
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
import { getReturnCarton, listReturnCartons, listReturnInventoryMovements, listReturnStock } from '../api/returnInventory'
import ReturnInventoryManagementView from './ReturnInventoryManagementView.vue'
vi.mock('@/modules/iam', () => ({
listPlatformTenants: vi.fn().mockResolvedValue({ records: [{ id: 'customer-1', tenantName: '测试客户', status: 'ACTIVE' }] }),
listSharedReceivingOverseasWarehouseOptions: vi.fn().mockResolvedValue([{ id: 'warehouse-1', warehouseName: '测试仓库' }]),
}))
vi.mock('../api/returnInventory', () => ({
listReturnStock: vi.fn(), listReturnCartons: vi.fn(), listReturnInventoryMovements: vi.fn(),
getReturnCarton: vi.fn(), getReturnCartonLabel: vi.fn(),
}))
const wrappers: VueWrapper[] = []
const stock = { customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', skuCode: 'SKU-TEST', totalQuantity: 8, availableQuantity: 6, cartonQuantity: 1, updatedAt: '2026-09-30' }
const carton = { customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', cartonMark: 'RETURN-BOX-1', status: 'AVAILABLE', skuTypes: 1, totalQuantity: 8, createdAt: '2026-09-30' }
async function mountView(mode: 'inventory' | 'cartons') {
const wrapper = mount(ReturnInventoryManagementView, {
props: { mode }, attachTo: document.body,
global: { plugins: [ElementPlus], provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'rollout-test') } },
})
wrappers.push(wrapper)
await flushPromises()
return wrapper
}
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
vi.mocked(listReturnStock).mockResolvedValue({ records: [stock], page: 1, size: 20, total: 45, pages: 3 })
vi.mocked(listReturnCartons).mockResolvedValue({ records: [carton], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(listReturnInventoryMovements).mockResolvedValue({ records: [{ ...stock, movementType: 'MERGE', referenceNo: 'MERGE-1', totalDelta: -2, availableDelta: -2, occurredAt: '2026-09-30' }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getReturnCarton).mockResolvedValue({ ...carton, items: [{ skuCode: 'SKU-TEST', quantity: 8 }] })
})
afterEach(() => {
wrappers.splice(0).forEach(wrapper => wrapper.unmount())
document.body.innerHTML = ''
})
describe('ReturnInventoryManagementView shared list', () => {
it('keeps query submission and server pagination when using the shared table', async () => {
const wrapper = await mountView('inventory')
expect(wrapper.get('h1').text()).toBe('退货库存管理')
expect(wrapper.text()).toContain('SKU-TEST')
expect(wrapper.find('button[aria-label="列设置"]').exists()).toBe(true)
await wrapper.get('input[placeholder="SKU"]').setValue('SKU-QUERY')
await wrapper.get('.query-panel button').trigger('click')
await flushPromises()
expect(listReturnStock).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, skuCode: 'SKU-QUERY' }))
wrapper.getComponent(DataTable).vm.$emit('update:page', 2)
await flushPromises()
expect(listReturnStock).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 20, skuCode: 'SKU-QUERY' }))
wrapper.getComponent(DataTable).vm.$emit('update:pageSize', 50)
await flushPromises()
expect(listReturnStock).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 50 }))
})
it('keeps stock and movement columns and saved widths independent', async () => {
const wrapper = await mountView('inventory')
wrapper.getComponent({ name: 'ElTable' }).vm.$emit('header-dragend', 260, 170, { columnKey: 'skuCode' })
await flushPromises()
const stockKey = preferenceStorageKey('table', 'rollout-test', 'overseas-return-stock')
expect(JSON.parse(localStorage.getItem(stockKey)!).widths.skuCode).toBe(260)
const tabs = wrapper.getComponent({ name: 'ElTabs' })
tabs.vm.$emit('update:modelValue', 'movements')
tabs.vm.$emit('tab-change', 'movements')
await flushPromises()
expect(listReturnInventoryMovements).toHaveBeenCalledWith(expect.objectContaining({ page: 1 }))
expect(wrapper.text()).toContain('退货合箱')
expect(wrapper.text()).toContain('MERGE-1')
expect(wrapper.getComponent(DataTable).props('preferenceKey')).toBe('overseas-return-movements')
const skuColumn = wrapper.findAllComponents({ name: 'ElTableColumn' }).find(column => column.props('columnKey') === 'skuCode')!
expect(skuColumn.props('width')).not.toBe(260)
})
it('keeps carton details reachable and the operation column fixed first', async () => {
const wrapper = await mountView('cartons')
const columns = wrapper.getComponent(DataTable).props('columns')
expect(columns[0]).toMatchObject({ key: 'actions', fixed: 'left', label: '操作' })
expect(wrapper.get('h1').text()).toBe('退货箱号管理')
const actions = wrapper.get('.el-table__body .admin-text-actions')
expect(actions.findAll('button').map(button => button.text())).toEqual(['详情', '箱唛'])
await actions.get('button').trigger('click')
await flushPromises()
expect(getReturnCarton).toHaveBeenCalledWith('RETURN-BOX-1')
expect(document.body.textContent).toContain('退货箱号详情')
expect(document.body.textContent).toContain('SKU-TEST')
})
})
@@ -4,6 +4,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import { Search } from '@lucide/vue' import { Search } from '@lucide/vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { listPlatformTenants, listSharedReceivingOverseasWarehouseOptions, type PlatformTenantResource } from '@/modules/iam' import { listPlatformTenants, listSharedReceivingOverseasWarehouseOptions, type PlatformTenantResource } from '@/modules/iam'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import { getReturnCarton, getReturnCartonLabel, listReturnCartons, listReturnInventoryMovements, listReturnStock, type ReturnCartonDetail, type ReturnCartonRow, type ReturnInventoryMovement, type ReturnStockRow } from '../api/returnInventory' import { getReturnCarton, getReturnCartonLabel, listReturnCartons, listReturnInventoryMovements, listReturnStock, type ReturnCartonDetail, type ReturnCartonRow, type ReturnInventoryMovement, type ReturnStockRow } from '../api/returnInventory'
@@ -47,15 +48,66 @@ async function loadOptions() {
async function openCarton(mark: string) { try { detail.value = await getReturnCarton(mark); detailOpen.value = true } catch (e) { ElMessage.error(e instanceof Error ? e.message : '箱号详情加载失败') } } async function openCarton(mark: string) { try { detail.value = await getReturnCarton(mark); detailOpen.value = true } catch (e) { ElMessage.error(e instanceof Error ? e.message : '箱号详情加载失败') } }
async function printLabel(mark: string) { try { const label = await getReturnCartonLabel(mark); const popup = window.open('', '_blank'); if (!popup) return ElMessage.warning('请允许浏览器打开打印窗口'); popup.opener = null; popup.document.open(); popup.document.write(label.printableHtml); popup.document.close(); popup.focus(); popup.print() } catch (e) { ElMessage.error(e instanceof Error ? e.message : '箱唛加载失败') } } async function printLabel(mark: string) { try { const label = await getReturnCartonLabel(mark); const popup = window.open('', '_blank'); if (!popup) return ElMessage.warning('请允许浏览器打开打印窗口'); popup.opener = null; popup.document.open(); popup.document.write(label.printableHtml); popup.document.close(); popup.focus(); popup.print() } catch (e) { ElMessage.error(e instanceof Error ? e.message : '箱唛加载失败') } }
onMounted(() => { void load(); void loadOptions() }) onMounted(() => { void load(); void loadOptions() })
const stockColumns: DataTableColumn[] = [
{ key: 'stock-0', label: '客户', minWidth: 180, slotName: 'stock-0' },
{ key: 'stock-1', label: '海外仓', minWidth: 180, slotName: 'stock-1' },
{ key: 'skuCode', label: 'SKU', prop: 'skuCode', minWidth: 170 },
{ key: 'productNameZh', label: '中文品名', prop: 'productNameZh', minWidth: 180 },
{ key: 'totalQuantity', label: '总库存', prop: 'totalQuantity', align: 'right' },
{ key: 'availableQuantity', label: '可用库存', prop: 'availableQuantity', align: 'right' },
{ key: 'updatedAt', label: '更新时间', prop: 'updatedAt', minWidth: 180 },
]
const movementColumns: DataTableColumn[] = [
{ key: 'movement-0', label: '客户', minWidth: 180, slotName: 'movement-0' },
{ key: 'movement-1', label: '海外仓', minWidth: 180, slotName: 'movement-1' },
{ key: 'skuCode', label: 'SKU', prop: 'skuCode', minWidth: 170 },
{ key: 'productNameZh', label: '中文品名', prop: 'productNameZh', minWidth: 180 },
{ key: 'movement-4', label: '业务类型', minWidth: 120, slotName: 'movement-4' },
{ key: 'referenceNo', label: '关联单号/箱号', prop: 'referenceNo', minWidth: 220 },
{ key: 'totalDelta', label: '总库存变动', prop: 'totalDelta', align: 'right' },
{ key: 'availableDelta', label: '可用库存变动', prop: 'availableDelta', align: 'right' },
{ key: 'occurredAt', label: '发生时间', prop: 'occurredAt', minWidth: 180 },
]
const cartonColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'carton-0' },
{ key: 'cartonMark', label: '箱号', prop: 'cartonMark', minWidth: 220 },
{ key: 'carton-2', label: '客户', minWidth: 180, slotName: 'carton-2' },
{ key: 'carton-3', label: '海外仓', minWidth: 180, slotName: 'carton-3' },
{ key: 'sourceReturnNo', label: '来源退货单', prop: 'sourceReturnNo', minWidth: 220 },
{ key: 'fbaCartonMark', label: 'FBA箱唛', prop: 'fbaCartonMark', minWidth: 180 },
{ key: 'carton-6', label: '目的仓', minWidth: 180, slotName: 'carton-6' },
{ key: 'relabelNo', label: '换标单号', prop: 'relabelNo', minWidth: 200 },
{ key: 'carton-8', label: '换标状态', width: 110, slotName: 'carton-8' },
{ key: 'carton-9', label: '状态', width: 100, slotName: 'carton-9' },
{ key: 'skuTypes', label: 'SKU种类', prop: 'skuTypes', align: 'right' },
{ key: 'totalQuantity', label: '总件数', prop: 'totalQuantity', align: 'right' },
{ key: 'carton-12', label: '箱规', minWidth: 190, slotName: 'carton-12' },
{ key: 'weight', label: '重量(kg)', prop: 'weight', align: 'right' },
{ key: 'createdAt', label: '创建时间', prop: 'createdAt', minWidth: 180 },
]
const tableRows = computed(() => props.mode === 'cartons' ? cartonRows.value : inventoryTab.value === 'stock' ? stockRows.value : movementRows.value)
const tableColumns = computed(() => props.mode === 'cartons' ? cartonColumns : inventoryTab.value === 'stock' ? stockColumns : movementColumns)
</script> </script>
<template><AdminPage as="div" class="business-page return-inventory-page admin-density"> <template><AdminPage as="div" class="business-page return-inventory-page admin-density">
<el-tabs class="density-element-tabs" v-if="mode==='inventory'" v-model="inventoryTab" @tab-change="changeInventoryTab"><el-tab-pane name="stock" ><template #label><span class="density-tab-icon" aria-hidden="true">🏷️</span><span>退货库存</span></template></el-tab-pane><el-tab-pane name="movements" ><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>退货进销存明细</span></template></el-tab-pane></el-tabs>
<section class="surface-panel query-panel"><el-select v-model="query.customerId" clearable filterable placeholder="全部客户" :loading="optionsLoading"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-select v-model="query.overseasWarehouseId" clearable filterable placeholder="全部海外仓" :loading="optionsLoading"><el-option v-for="item in warehouseOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.cartonMark" clearable placeholder="箱号/箱唛" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" /><el-select v-if="mode==='cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select><el-button type="primary" @click="search"><Search :size="16" />查询</el-button></section>
<section class="surface-panel table-panel"><el-table v-if="mode==='inventory' && inventoryTab==='stock'" v-loading="loading" :data="stockRows"><el-table-column label="客户" min-width="180"><template #default="{row}">{{ customerName(row.customerId) }}</template></el-table-column><el-table-column label="海外仓" min-width="180"><template #default="{row}">{{ warehouseName(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="170" /><el-table-column prop="productNameZh" label="中文品名" min-width="180" /><el-table-column prop="totalQuantity" label="总库存" align="right" /><el-table-column prop="availableQuantity" label="可用库存" align="right" /><el-table-column prop="updatedAt" label="更新时间" min-width="180" /></el-table> <DataTable appearance="integrated" :stripe="false" column-settings-mode="show" v-model:page="query.page" v-model:page-size="query.size" :data="tableRows" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :preference-key="`overseas-return-${mode === 'inventory' ? inventoryTab : 'cartons'}`" @update:page="load" @update:page-size="query.page = 1; load()">
<el-table v-else-if="mode==='inventory'" v-loading="loading" :data="movementRows"><el-table-column label="客户" min-width="180"><template #default="{row}">{{ customerName(row.customerId) }}</template></el-table-column><el-table-column label="海外仓" min-width="180"><template #default="{row}">{{ warehouseName(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="170" /><el-table-column prop="productNameZh" label="中文品名" min-width="180" /><el-table-column label="业务类型" min-width="120"><template #default="{row}">{{ row.movementType === 'RECEIPT' ? '退货签收' : row.movementType === 'MERGE' ? '退货合箱' : '退货出库' }}</template></el-table-column><el-table-column prop="referenceNo" label="关联单号/箱号" min-width="220" /><el-table-column prop="totalDelta" label="总库存变动" align="right" /><el-table-column prop="availableDelta" label="可用库存变动" align="right" /><el-table-column prop="occurredAt" label="发生时间" min-width="180" /></el-table> <template #toolbar-start><h1>{{ mode === 'inventory' ? '退货库存管理' : '退货箱号管理' }}</h1></template>
<el-table v-else v-loading="loading" :data="cartonRows"><el-table-column label="操作" width="140" fixed="left"><template #default="{row}"><div class="density-actions"><el-button class="density-action" data-tone="neutral" @click="openCarton(row.cartonMark)">详情</el-button><el-button class="density-action" data-tone="neutral" @click="printLabel(row.cartonMark)">箱唛</el-button></div></template></el-table-column><el-table-column prop="cartonMark" label="箱号" min-width="220" /><el-table-column label="客户" min-width="180"><template #default="{row}">{{ customerName(row.customerId) }}</template></el-table-column><el-table-column label="海外仓" min-width="180"><template #default="{row}">{{ warehouseName(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="sourceReturnNo" label="来源退货单" min-width="220" /><el-table-column prop="fbaCartonMark" label="FBA箱唛" min-width="180" /><el-table-column label="目的仓" min-width="180"><template #default="{row}">{{ row.destinationWarehouseCode || row.destinationWarehouseName || '—' }}</template></el-table-column><el-table-column prop="relabelNo" label="换标单号" min-width="200" /><el-table-column label="换标状态" width="110"><template #default="{row}">{{ row.relabelStatus === 'COMPLETED' ? '已完成' : row.relabelStatus === 'BOUND' ? '已绑定' : row.relabelStatus || '未绑定' }}</template></el-table-column><el-table-column label="状态" width="100"><template #default="{row}">{{ statusLabel(row.status) }}</template></el-table-column><el-table-column prop="skuTypes" label="SKU种类" align="right" /><el-table-column prop="totalQuantity" label="总件数" align="right" /><el-table-column label="箱规" min-width="190"><template #default="{row}">{{ row.length ? `${row.length}×${row.width}×${row.height} cm` : '—' }}</template></el-table-column><el-table-column prop="weight" label="重量(kg)" align="right" /><el-table-column prop="createdAt" label="创建时间" min-width="180" /></el-table> <template #filters><el-tabs class="density-element-tabs" v-if="mode==='inventory'" v-model="inventoryTab" @tab-change="changeInventoryTab"><el-tab-pane name="stock" ><template #label><span class="density-tab-icon" aria-hidden="true">🏷️</span><span>退货库存</span></template></el-tab-pane><el-tab-pane name="movements" ><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>退货进销存明细</span></template></el-tab-pane></el-tabs><section class="query-toolbar query-panel"><el-select v-model="query.customerId" clearable filterable placeholder="全部客户" :loading="optionsLoading"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-select v-model="query.overseasWarehouseId" clearable filterable placeholder="全部海外仓" :loading="optionsLoading"><el-option v-for="item in warehouseOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.cartonMark" clearable placeholder="箱号/箱唛" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" /><el-select v-if="mode==='cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select><el-button type="primary" @click="search"><Search :size="16" />查询</el-button></section></template>
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" layout="total, sizes, prev, pager, next" :page-sizes="[20,50,100]" @current-change="load" @size-change="query.page=1;load()" /></section> <template #stock-0="{ row }">{{ customerName(row.customerId) }}</template>
<template #stock-1="{ row }">{{ warehouseName(row.overseasWarehouseId) }}</template>
<template #movement-0="{ row }">{{ customerName(row.customerId) }}</template>
<template #movement-1="{ row }">{{ warehouseName(row.overseasWarehouseId) }}</template>
<template #movement-4="{ row }">{{ row.movementType === 'RECEIPT' ? '退货签收' : row.movementType === 'MERGE' ? '退货合箱' : '退货出库' }}</template>
<template #carton-0="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" @click="openCarton(row.cartonMark)">详情</el-button><el-button class="density-action" data-tone="neutral" @click="printLabel(row.cartonMark)">箱唛</el-button></div></template>
<template #carton-2="{ row }">{{ customerName(row.customerId) }}</template>
<template #carton-3="{ row }">{{ warehouseName(row.overseasWarehouseId) }}</template>
<template #carton-6="{ row }">{{ row.destinationWarehouseCode || row.destinationWarehouseName || '—' }}</template>
<template #carton-8="{ row }">{{ row.relabelStatus === 'COMPLETED' ? '已完成' : row.relabelStatus === 'BOUND' ? '已绑定' : row.relabelStatus || '未绑定' }}</template>
<template #carton-9="{ row }">{{ statusLabel(row.status) }}</template>
<template #carton-12="{ row }">{{ row.length ? `${row.length}×${row.width}×${row.height} cm` : '—' }}</template>
</DataTable>
<AppDialog class="admin-density-dialog" v-model="detailOpen" title="退货箱号详情" width="min(1080px, calc(100vw - 56px))" :show-footer="false"><template v-if="detail"><el-descriptions class="density-descriptions" :column="2" border><el-descriptions-item label="箱号">{{detail.cartonMark}}</el-descriptions-item><el-descriptions-item label="客户">{{customerName(detail.customerId)}}</el-descriptions-item><el-descriptions-item label="海外仓">{{warehouseName(detail.overseasWarehouseId)}}</el-descriptions-item><el-descriptions-item label="来源退货单">{{detail.sourceReturnNo||'—'}}</el-descriptions-item><el-descriptions-item label="FBA箱唛">{{detail.fbaCartonMark||'—'}}</el-descriptions-item><el-descriptions-item label="目的仓">{{detail.destinationWarehouseCode||detail.destinationWarehouseName||'—'}}</el-descriptions-item><el-descriptions-item label="换标单号">{{detail.relabelNo||'—'}}</el-descriptions-item><el-descriptions-item label="换标状态">{{detail.relabelStatus === 'COMPLETED' ? '已完成' : detail.relabelStatus === 'BOUND' ? '已绑定' : detail.relabelStatus || '未绑定'}}</el-descriptions-item><el-descriptions-item label="状态">{{statusLabel(detail.status)}}</el-descriptions-item><el-descriptions-item label="箱规">{{detail.length?`${detail.length}×${detail.width}×${detail.height} cm`:'—'}}</el-descriptions-item><el-descriptions-item label="重量">{{detail.weight?`${detail.weight} kg`:'—'}}</el-descriptions-item></el-descriptions><el-table :data="detail.items" class="detail-items"><el-table-column prop="skuCode" label="SKU" /><el-table-column prop="productNameZh" label="中文品名" /><el-table-column prop="quantity" label="数量" align="right" /></el-table></template></AppDialog> <AppDialog class="admin-density-dialog" v-model="detailOpen" title="退货箱号详情" width="min(1080px, calc(100vw - 56px))" :show-footer="false"><template v-if="detail"><el-descriptions class="density-descriptions" :column="2" border><el-descriptions-item label="箱号">{{detail.cartonMark}}</el-descriptions-item><el-descriptions-item label="客户">{{customerName(detail.customerId)}}</el-descriptions-item><el-descriptions-item label="海外仓">{{warehouseName(detail.overseasWarehouseId)}}</el-descriptions-item><el-descriptions-item label="来源退货单">{{detail.sourceReturnNo||'—'}}</el-descriptions-item><el-descriptions-item label="FBA箱唛">{{detail.fbaCartonMark||'—'}}</el-descriptions-item><el-descriptions-item label="目的仓">{{detail.destinationWarehouseCode||detail.destinationWarehouseName||'—'}}</el-descriptions-item><el-descriptions-item label="换标单号">{{detail.relabelNo||'—'}}</el-descriptions-item><el-descriptions-item label="换标状态">{{detail.relabelStatus === 'COMPLETED' ? '已完成' : detail.relabelStatus === 'BOUND' ? '已绑定' : detail.relabelStatus || '未绑定'}}</el-descriptions-item><el-descriptions-item label="状态">{{statusLabel(detail.status)}}</el-descriptions-item><el-descriptions-item label="箱规">{{detail.length?`${detail.length}×${detail.width}×${detail.height} cm`:'—'}}</el-descriptions-item><el-descriptions-item label="重量">{{detail.weight?`${detail.weight} kg`:'—'}}</el-descriptions-item></el-descriptions><el-table :data="detail.items" class="detail-items"><el-table-column prop="skuCode" label="SKU" /><el-table-column prop="productNameZh" label="中文品名" /><el-table-column prop="quantity" label="数量" align="right" /></el-table></template></AppDialog>
</AdminPage></template> </AdminPage></template>
<style scoped>.return-inventory-page{display:grid;gap:var(--admin-space-5)}.query-panel{display:flex;gap:var(--admin-space-4);flex-wrap:wrap;padding:var(--admin-space-5)}.query-panel .el-input,.query-panel .el-select{width:180px}.table-panel{padding:var(--admin-space-5)}.el-pagination{justify-content:flex-end;margin-top:var(--admin-space-5)}.detail-items{margin-top:var(--admin-space-5)}</style> <style scoped>.return-inventory-page{display:grid;gap:var(--admin-space-5)}.query-panel{display:flex;gap:var(--admin-space-4);flex-wrap:wrap;padding:var(--admin-space-5)}.query-panel .el-input,.query-panel .el-select{width:180px}.table-panel{padding:var(--admin-space-5)}.el-pagination{justify-content:flex-end;margin-top:var(--admin-space-5)}.detail-items{margin-top:var(--admin-space-5)}</style>
@@ -244,7 +244,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toMatch(/total\.value = \w+\.total/) expect(value).toMatch(/total\.value = \w+\.total/)
expect(value).toContain('v-model:page="page"') expect(value).toContain('v-model:page="page"')
expect(value).toContain('v-model:page-size="pageSize"') expect(value).toContain('v-model:page-size="pageSize"')
expect(value).toContain(':total="total"') expect(value).toMatch(/:total="(?:total|statusTab === '柜子' \? undefined : total)"/)
expect(value).toContain('@update:page="load()"') expect(value).toContain('@update:page="load()"')
expect(value).toContain('@update:page-size="page = 1; load()"') expect(value).toContain('@update:page-size="page = 1; load()"')
} }
@@ -341,11 +341,11 @@ describe('overseas warehouse first-batch prototype contract', () => {
const value = source(fileName) const value = source(fileName)
expect(value).toContain(routeName) expect(value).toContain(routeName)
if (fileName === 'DispatchesView.vue') { if (fileName === 'DispatchesView.vue') {
expect(value).toContain('<RowActions :actions="rowActions(row as DispatchListItem)"') expect(value).toMatch(/<RowActions\b[^>]*:actions="rowActions\(row as DispatchListItem\)"/)
expect(value).toContain("{ key: 'detail', label: '查看', priority: 300 }") expect(value).toContain("{ key: 'detail', label: '查看', priority: 300 }")
expect(value).toContain("if (command === 'detail') openDetail(row)") expect(value).toContain("if (command === 'detail') openDetail(row)")
} else if (fileName === 'LastMileOrdersView.vue') { } else if (fileName === 'LastMileOrdersView.vue') {
expect(value).toContain('<RowActions :actions="lastMileRowActions(row as LastMileOrderListItem)"') expect(value).toContain('<RowActions text-actions :actions="lastMileRowActions(row as LastMileOrderListItem)"')
expect(value).toContain("{ key: 'detail', label: '查看', priority: 500 }") expect(value).toContain("{ key: 'detail', label: '查看', priority: 500 }")
expect(value).toContain("if (command === 'detail') openDetail(row)") expect(value).toContain("if (command === 'detail') openDetail(row)")
} else { } else {
+15 -15
View File
@@ -6,7 +6,7 @@ export const overseasWarehouseRoutes = [
path: 'overseas-warehouse/last-mile-orders', path: 'overseas-warehouse/last-mile-orders',
name: 'overseas-last-mile-orders', name: 'overseas-last-mile-orders',
component: () => import('./pages/LastMileOrdersView.vue'), component: () => import('./pages/LastMileOrdersView.vue'),
meta: { title: '尾程订单', description: '管理海外接柜后的尾程订单与入仓衔接', permission: permission.lastMileOrderList }, meta: { showPageHeading: false, title: '尾程订单', description: '管理海外接柜后的尾程订单与入仓衔接', permission: permission.lastMileOrderList },
}, },
{ {
path: 'overseas-warehouse/last-mile-orders/:orderId', path: 'overseas-warehouse/last-mile-orders/:orderId',
@@ -18,7 +18,7 @@ export const overseasWarehouseRoutes = [
path: 'overseas-warehouse/inbound-orders', path: 'overseas-warehouse/inbound-orders',
name: 'overseas-inbound-orders', name: 'overseas-inbound-orders',
component: () => import('./pages/InboundOrdersView.vue'), component: () => import('./pages/InboundOrdersView.vue'),
meta: { title: '入库单', description: '管理海外合作仓入库、预约与排车衔接', permission: permission.inboundOrderList }, meta: { showPageHeading: false, title: '入库单', description: '管理海外合作仓入库、预约与排车衔接', permission: permission.inboundOrderList },
}, },
{ {
path: 'overseas-warehouse/inbound-orders/:orderId', path: 'overseas-warehouse/inbound-orders/:orderId',
@@ -30,7 +30,7 @@ export const overseasWarehouseRoutes = [
path: 'overseas-warehouse/dispatches', path: 'overseas-warehouse/dispatches',
name: 'overseas-dispatches', name: 'overseas-dispatches',
component: () => import('./pages/DispatchesView.vue'), component: () => import('./pages/DispatchesView.vue'),
meta: { title: '排车列表', description: '查看海外仓车辆调度、预约与发车状态', permission: permission.dispatchList }, meta: { title: '排车列表', description: '查看海外仓车辆调度、预约与发车状态', permission: permission.dispatchList, showPageHeading: false },
}, },
{ {
path: 'overseas-warehouse/dispatches/:dispatchNo', path: 'overseas-warehouse/dispatches/:dispatchNo',
@@ -42,7 +42,7 @@ export const overseasWarehouseRoutes = [
path: 'overseas-warehouse/outbounds', path: 'overseas-warehouse/outbounds',
name: 'overseas-outbounds', name: 'overseas-outbounds',
component: () => import('./pages/OutboundsView.vue'), component: () => import('./pages/OutboundsView.vue'),
meta: { title: '出库单', description: '查询海外仓出库单并创建一件代发出库', permission: permission.outboundList }, meta: { showPageHeading: false, title: '出库单', description: '查询海外仓出库单并创建一件代发出库', permission: permission.outboundList },
}, },
{ {
path: 'overseas-warehouse/outbounds/:outboundNo', path: 'overseas-warehouse/outbounds/:outboundNo',
@@ -54,7 +54,7 @@ export const overseasWarehouseRoutes = [
path: 'overseas-warehouse/delivery-orders', path: 'overseas-warehouse/delivery-orders',
name: 'overseas-delivery-orders', name: 'overseas-delivery-orders',
component: () => import('./pages/DeliveryOrdersView.vue'), component: () => import('./pages/DeliveryOrdersView.vue'),
meta: { title: '派送单列表', description: '查询海外仓派送订单及履约信息', permission: permission.deliveryList }, meta: { showPageHeading: false, title: '派送单列表', description: '查询海外仓派送订单及履约信息', permission: permission.deliveryList },
}, },
{ {
path: 'overseas-warehouse/delivery-orders/:deliveryNo', path: 'overseas-warehouse/delivery-orders/:deliveryNo',
@@ -66,39 +66,39 @@ export const overseasWarehouseRoutes = [
path: 'overseas-warehouse/exception-orders', path: 'overseas-warehouse/exception-orders',
name: 'overseas-exception-orders', name: 'overseas-exception-orders',
component: () => import('./pages/ExceptionOrdersView.vue'), component: () => import('./pages/ExceptionOrdersView.vue'),
meta: { title: '异常订单', description: '查询和新增海外仓异常、拒收及退货单据', permission: permission.exceptionOrderList }, meta: { showPageHeading: false, title: '异常订单', description: '查询和新增海外仓异常、拒收及退货单据', permission: permission.exceptionOrderList },
}, },
{ {
path: 'overseas-warehouse/customer-visits', path: 'overseas-warehouse/customer-visits',
name: 'overseas-customer-visits', name: 'overseas-customer-visits',
component: () => import('./pages/CustomerVisitsView.vue'), component: () => import('./pages/CustomerVisitsView.vue'),
meta: { title: '客户回访', description: '跟进已完成派送单并维护入仓单维度回访资料', permission: permission.customerVisitList }, meta: { showPageHeading: false, title: '客户回访', description: '跟进已完成派送单并维护入仓单维度回访资料', permission: permission.customerVisitList },
}, },
{ {
path: 'overseas-warehouse/inventory', path: 'overseas-warehouse/inventory',
name: 'overseas-inventory', name: 'overseas-inventory',
component: () => import('./pages/InventoryView.vue'), component: () => import('./pages/InventoryView.vue'),
meta: { title: '库存管理', description: '查看海外仓库存汇总、可用量和库内分布', permission: permission.inventoryList }, meta: { showPageHeading: false, title: '库存管理', description: '查看海外仓库存汇总、可用量和库内分布', permission: permission.inventoryList },
}, },
{ {
path: 'overseas-warehouse/return-inventory', path: 'overseas-warehouse/return-inventory',
name: 'overseas-return-inventory', name: 'overseas-return-inventory',
component: () => import('./pages/ReturnInventoryManagementView.vue'), component: () => import('./pages/ReturnInventoryManagementView.vue'),
props: { mode: 'inventory' }, props: { mode: 'inventory' },
meta: { title: '退货库存管理', description: '按客户和 SKU 查询退货签收后的可用库存', permission: permission.returnInventoryList }, meta: { showPageHeading: false, title: '退货库存管理', description: '按客户和 SKU 查询退货签收后的可用库存', permission: permission.returnInventoryList },
}, },
{ {
path: 'overseas-warehouse/return-cartons', path: 'overseas-warehouse/return-cartons',
name: 'overseas-return-cartons', name: 'overseas-return-cartons',
component: () => import('./pages/ReturnInventoryManagementView.vue'), component: () => import('./pages/ReturnInventoryManagementView.vue'),
props: { mode: 'cartons' }, props: { mode: 'cartons' },
meta: { title: '退货箱号管理', description: '查询合箱后的箱号、箱规和箱内 SKU', permission: permission.returnCartonsList }, meta: { showPageHeading: false, title: '退货箱号管理', description: '查询合箱后的箱号、箱规和箱内 SKU', permission: permission.returnCartonsList },
}, },
{ {
path: 'overseas-warehouse/inventory-transactions', path: 'overseas-warehouse/inventory-transactions',
name: 'overseas-inventory-transactions', name: 'overseas-inventory-transactions',
component: () => import('./pages/InventoryTransactionsView.vue'), component: () => import('./pages/InventoryTransactionsView.vue'),
meta: { title: '进销存记录', description: '查看海外仓库存的只读变动流水', permission: permission.inventoryList }, meta: { showPageHeading: false, title: '进销存记录', description: '查看海外仓库存的只读变动流水', permission: permission.inventoryList },
}, },
{ {
path: 'overseas-warehouse/inventory/:inventoryId', path: 'overseas-warehouse/inventory/:inventoryId',
@@ -110,7 +110,7 @@ export const overseasWarehouseRoutes = [
path: 'overseas-warehouse/relabel-orders', path: 'overseas-warehouse/relabel-orders',
name: 'overseas-relabel-orders', name: 'overseas-relabel-orders',
component: () => import('./pages/RelabelOrdersView.vue'), component: () => import('./pages/RelabelOrdersView.vue'),
meta: { title: '换标单', description: '管理海外仓换标作业要求与费用信息', permission: permission.relabelOrderList }, meta: { showPageHeading: false, title: '换标单', description: '管理海外仓换标作业要求与费用信息', permission: permission.relabelOrderList },
}, },
{ {
path: 'overseas-warehouse/relabel-orders/:orderId', path: 'overseas-warehouse/relabel-orders/:orderId',
@@ -122,7 +122,7 @@ export const overseasWarehouseRoutes = [
path: 'overseas-warehouse/pallets', path: 'overseas-warehouse/pallets',
name: 'overseas-pallets', name: 'overseas-pallets',
component: () => import('./pages/PalletsView.vue'), component: () => import('./pages/PalletsView.vue'),
meta: { title: '托盘管理', description: '管理海外仓托盘主数据与使用状态', permission: permission.palletList }, meta: { showPageHeading: false, title: '托盘管理', description: '管理海外仓托盘主数据与使用状态', permission: permission.palletList },
}, },
{ {
path: 'overseas-warehouse/pallets/:palletId', path: 'overseas-warehouse/pallets/:palletId',
@@ -134,12 +134,12 @@ export const overseasWarehouseRoutes = [
path: 'overseas-warehouse/locations', path: 'overseas-warehouse/locations',
name: 'overseas-locations', name: 'overseas-locations',
component: () => import('./pages/LocationsView.vue'), component: () => import('./pages/LocationsView.vue'),
meta: { title: '库位管理', description: '维护海外仓普通库位与 A0 共享溢出位', permission: permission.locationList }, meta: { showPageHeading: false, title: '库位管理', description: '维护海外仓普通库位与 A0 共享溢出位', permission: permission.locationList },
}, },
{ {
path: 'general/mojia-sku-sync', path: 'general/mojia-sku-sync',
name: 'mojia-sku-sync', name: 'mojia-sku-sync',
component: () => import('./pages/MojiaSkuSyncView.vue'), component: () => import('./pages/MojiaSkuSyncView.vue'),
meta: { title: '称重机器', description: '查看墨家机器上传的箱码和 SKU 测量记录', permission: permission.mojiaSkuSyncList }, meta: { showPageHeading: false, title: '称重机器', description: '查看墨家机器上传的箱码和 SKU 测量记录', permission: permission.mojiaSkuSyncList },
}, },
] satisfies RouteRecordRaw[] ] satisfies RouteRecordRaw[]
@@ -26,10 +26,11 @@ describe('PDA release workbench', () => {
}) })
it('uses the standard dense list workspace without repeating the route title', () => { it('uses the standard dense list workspace without repeating the route title', () => {
expect(page).toContain('class="surface-panel table-panel pda-release-panel"') expect(page).toContain('appearance="integrated" :stripe="false"')
expect(page).toContain('<template #toolbar-start>') expect(page).toContain('<template #toolbar-start>')
expect(page).toContain('<template #toolbar-actions>') expect(page).toContain('<template #toolbar-actions>')
expect(page).toContain('class="pda-release-actions density-actions"') expect(page).toContain('class="pda-release-actions density-actions row-actions admin-text-actions"')
expect(page).not.toContain('<h1>PDA版本</h1>') expect(page).toContain('<h1>PDA版本</h1>')
expect(pdaReleaseRoutes[0]?.meta?.showPageHeading).toBe(false)
}) })
}) })
@@ -2,7 +2,7 @@
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, Plus, RefreshCw } from '@lucide/vue' import { Plus, RefreshCw } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -121,11 +121,10 @@ function download(row: PdaRelease) {
<template> <template>
<AdminPage class="admin-density pda-releases-page"> <AdminPage class="admin-density pda-releases-page">
<ListPageLayout aria-label="PDA版本"> <ListPageLayout aria-label="PDA版本">
<section class="surface-panel table-panel pda-release-panel" :aria-busy="loading"> <DataTable appearance="integrated" :stripe="false" column-settings-mode="show" preference-key="pda-releases-table" :data="rows" :columns="columns" :loading="loading" :aria-busy="loading" row-key="id" border empty-text="暂无版本记录">
<el-alert v-if="error" class="pda-release-error" type="error" :title="error" show-icon :closable="false" /> <template #toolbar-start><h1>PDA版本</h1></template>
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="id" border stripe empty-text="暂无版本记录"> <template #filters>
<template #toolbar-start> <div class="pda-release-context query-toolbar">
<div class="pda-release-context">
<el-radio-group v-model="app" size="small" aria-label="选择PDA应用"> <el-radio-group v-model="app" size="small" aria-label="选择PDA应用">
<el-radio-button value="domestic">国内 PDA</el-radio-button> <el-radio-button value="domestic">国内 PDA</el-radio-button>
<el-radio-button value="overseas">海外 PDA</el-radio-button> <el-radio-button value="overseas">海外 PDA</el-radio-button>
@@ -136,13 +135,16 @@ function download(row: PdaRelease) {
</dl> </dl>
</div> </div>
</template> </template>
<template #notices><el-alert v-if="error" class="pda-release-error" type="error" :title="error" show-icon :closable="false" /></template>
<template #toolbar-actions> <template #toolbar-actions>
<el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button> <el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button>
<el-button v-if="canManage" type="primary" @click="openUpload"><Plus :size="16" />上传版本</el-button> <el-button v-if="canManage" type="primary" @click="openUpload"><Plus :size="16" />上传版本</el-button>
</template> </template>
<template #actions="{ row }"> <template #actions="{ row }">
<div class="pda-release-actions density-actions"> <div class="pda-release-actions density-actions row-actions admin-text-actions">
<el-button v-if="row.packageType === 'apk' && row.downloadUrl" class="density-action" data-tone="neutral" @click="download(asRelease(row))"><Download :size="15" />下载 APK</el-button> <el-button v-if="row.packageType === 'apk' && row.downloadUrl" class="density-action" data-tone="neutral" @click="download(asRelease(row))">下载 APK</el-button>
<el-button v-if="canManage && row.status === 'DRAFT'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'publish')">发布</el-button> <el-button v-if="canManage && row.status === 'DRAFT'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'publish')">发布</el-button>
<el-button v-if="canManage && row.current" class="density-action" data-tone="danger" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'withdraw')">停发</el-button> <el-button v-if="canManage && row.current" class="density-action" data-tone="danger" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'withdraw')">停发</el-button>
</div> </div>
@@ -155,7 +157,6 @@ function download(row: PdaRelease) {
<template #createdAt="{ row }">{{ row.createdAt?.replace('T', ' ').slice(0, 19) || '—' }}</template> <template #createdAt="{ row }">{{ row.createdAt?.replace('T', ' ').slice(0, 19) || '—' }}</template>
<template #publishedAt="{ row }">{{ row.publishedAt?.replace('T', ' ').slice(0, 19) || '—' }}</template> <template #publishedAt="{ row }">{{ row.publishedAt?.replace('T', ' ').slice(0, 19) || '—' }}</template>
</DataTable> </DataTable>
</section>
</ListPageLayout> </ListPageLayout>
<AppDialog v-model="uploadOpen" class="admin-density-dialog" title="上传PDA版本" width="min(640px, calc(100vw - 28px))" confirm-text="保存草稿" :confirm-loading="uploading" @confirm="upload"> <AppDialog v-model="uploadOpen" class="admin-density-dialog" title="上传PDA版本" width="min(640px, calc(100vw - 28px))" confirm-text="保存草稿" :confirm-loading="uploading" @confirm="upload">
+1 -1
View File
@@ -5,6 +5,6 @@ export const pdaReleaseRoutes = [
path: 'system/pda-releases', path: 'system/pda-releases',
name: 'pda-releases', name: 'pda-releases',
component: () => import('@/modules/pda-releases/pages/PdaReleasesView.vue'), component: () => import('@/modules/pda-releases/pages/PdaReleasesView.vue'),
meta: { title: 'PDA版本', description: '国内与海外PDA安装包下载和热更新发布', permission: 'platform:pda-release:list' }, meta: { showPageHeading: false, title: 'PDA版本', description: '国内与海外PDA安装包下载和热更新发布', permission: 'platform:pda-release:list' },
}, },
] satisfies RouteRecordRaw[] ] satisfies RouteRecordRaw[]
@@ -0,0 +1,85 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listSailingCalendar } from '../api/platformSailingCalendar'
import type { SailingCalendarResponse } from '../types'
import SailingCalendarView from './SailingCalendarView.vue'
const permissions = vi.hoisted(() => ({ write: true }))
vi.mock('@/core/access/accessPreview', () => ({
useAccessPreviewStore: () => ({ can: () => permissions.write }),
}))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) }))
vi.mock('../api/platformSailingCalendar', () => ({
listSailingCalendar: vi.fn(), cancelSailing: vi.fn(), copySailing: vi.fn(),
createSailingTemplate: vi.fn(), createSailing: vi.fn(), generateSailingTemplate: vi.fn(),
getSailing: vi.fn(), getSailingBookingAdvice: vi.fn(), getSailingVolumeDetail: vi.fn(),
listSailingChannels: vi.fn().mockResolvedValue([]), listSailingTemplates: vi.fn().mockResolvedValue([]),
previewSailingTemplateGeneration: vi.fn(), updateSailingTemplate: vi.fn(), updateSailing: vi.fn(),
}))
const volume = { actualReceivedCbm: 12, catchableForecastCbm: 0, riskUnmatchedCbm: 0, bookedContainerCount: 0, recommendedContainerCount: 1, pendingContainerCount: 1, lateBatchCount: 0, overCapacity: false }
const calendar: SailingCalendarResponse = {
month: '2026-09', timezone: 'Asia/Shanghai', summary: { ...volume, sailingCount: 1 },
filterOptions: { warehouses: [], channels: [], departurePorts: [], destinationPorts: [] },
records: [{ id: 'sailing-1', sailingNo: 'SAIL-TEST', warehouseId: 'warehouse-1', warehouseName: '测试仓', channelCode: 'CH-TEST', channelName: '测试渠道', vesselName: 'TEST VESSEL', voyageNo: 'TEST-01', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', etdAt: '2026-09-30T10:00:00', cutoffAt: '2026-09-29T18:00:00', status: 'OPEN', volumeSummary: volume, version: 1 }],
}
let wrapper: VueWrapper | undefined
async function mountView() {
wrapper = mount(SailingCalendarView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
return wrapper
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date(2026, 8, 30, 12))
vi.clearAllMocks()
permissions.write = true
vi.mocked(listSailingCalendar).mockResolvedValue(calendar)
})
afterEach(() => { wrapper?.unmount(); wrapper = undefined; document.body.innerHTML = ''; vi.useRealTimers() })
describe('SailingCalendarView integrated workspace', () => {
it('preserves keyword search, date selection and month navigation with the shared header', async () => {
const view = await mountView()
expect(view.get('h1').text()).toBe('船期日历')
expect(view.get('.data-table__business-actions').text()).toContain('新增单个船次')
expect(view.find('button[aria-label="列设置"]').exists()).toBe(false)
expect(view.find('.data-table__pagination').exists()).toBe(false)
expect(view.get('.detail-panel').text()).toContain('SAIL-TEST')
await view.get('.sailing-card').trigger('click')
expect(view.get('.detail-actions').text()).toContain('货量明细')
const otherDate = view.findAll('.calendar-cell').find(cell => cell.get('.day-number').text() === '29')!
await otherDate.trigger('click')
expect(view.get('.detail-header h2').text()).toBe('2026-09-29')
expect(view.get('.detail-panel').text()).toContain('当天暂无船期')
const keyword = view.get('input[placeholder="渠道 / 船名 / 航次 / 船期编号"]')
await keyword.setValue('TEST VESSEL')
await keyword.trigger('keyup', { key: 'Enter' })
await flushPromises()
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-09', keyword: 'TEST VESSEL' }))
await view.get('.toolbar-filters button').trigger('click')
await flushPromises()
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ keyword: undefined }))
await view.get('button[aria-label="下个月"]').trigger('click')
await flushPromises()
expect(listSailingCalendar).toHaveBeenLastCalledWith(expect.objectContaining({ month: '2026-10' }))
})
it('keeps filters and retry available on failure and preserves read-only permissions', async () => {
permissions.write = false
vi.mocked(listSailingCalendar).mockRejectedValueOnce(new Error('日历加载失败'))
const view = await mountView()
expect(view.get('h1').text()).toBe('船期日历')
expect(view.find('.toolbar-filters').exists()).toBe(true)
expect(view.find('.calendar-layout').exists()).toBe(false)
expect(view.find('.data-table__business-actions button').exists()).toBe(false)
await view.get('.load-error-state__actions button').trigger('click')
await flushPromises()
expect(view.get('.calendar-layout').attributes('aria-busy')).toBe('false')
expect(view.get('.detail-panel').text()).toContain('SAIL-TEST')
await view.get('.sailing-card').trigger('click')
const operations = view.get('.detail-actions').text()
expect(operations).toContain('货量明细')
for (const action of ['编辑', '复制下一班', '取消']) expect(operations).not.toContain(action)
})
})
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,5 +4,5 @@ export const sailingCalendarRoutes = [{
path: 'sailing-calendar', path: 'sailing-calendar',
name: 'sailing-calendar', name: 'sailing-calendar',
component: () => import('./pages/SailingCalendarView.vue'), component: () => import('./pages/SailingCalendarView.vue'),
meta: { title: '船期日历', description: '维护计划船期、查看收货体积和订柜建议', permission: 'platform:firstleg:sailing-calendar:list' }, meta: { title: '船期日历', description: '维护计划船期、查看收货体积和订柜建议', showPageHeading: false, permission: 'platform:firstleg:sailing-calendar:list' },
}] satisfies RouteRecordRaw[] }] satisfies RouteRecordRaw[]
@@ -22,13 +22,13 @@ describe('supplier formal pages', () => {
expect(source).toContain("access.can('platform:supplier:status')") expect(source).toContain("access.can('platform:supplier:status')")
expect(source).toContain("access.can('platform:supplier:list')") expect(source).toContain("access.can('platform:supplier:list')")
expect(source).toContain('class="density-action"') expect(source).toContain('class="density-action"')
expect(source).toContain('class="row-actions density-actions"') expect(source).toContain('class="row-actions density-actions admin-text-actions"')
expect(source).toContain("statusLabels[supplierRow(row).status] ?? '—'") expect(source).toContain("statusLabels[supplierRow(row).status] ?? '—'")
}) })
it('matches the prototype statistics, category tabs and search sequence', () => { it('matches the prototype statistics, category tabs and search sequence', () => {
expect(source).toContain('class="supplier-list-top"') expect(source).toContain('class="supplier-list-top"')
expect(source).toContain('class="supplier-query-panel"') expect(source).toContain('class="supplier-query-panel integrated-query-group"')
expect(source).toContain('class="supplier-query-toolbar"') expect(source).toContain('class="supplier-query-toolbar"')
expect(source).toContain('class="supplier-tabs density-tabs"') expect(source).toContain('class="supplier-tabs density-tabs"')
expect(source).toContain('({{ item.value }})') expect(source).toContain('({{ item.value }})')
+13 -12
View File
@@ -124,9 +124,13 @@ onActivated(() => {
<template> <template>
<AdminPage class="admin-density"> <AdminPage class="admin-density">
<ListPageLayout aria-label="供应商列表"> <ListPageLayout aria-label="供应商列表">
<template #toolbar>
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="canView" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" empty-text="暂无供应商数据" preference-key="supplier-list">
<template #toolbar-start><h1>供应商列表</h1></template>
<template #filters>
<div class="supplier-list-top"> <div class="supplier-list-top">
<div class="supplier-query-panel"> <div class="supplier-query-panel integrated-query-group">
<nav class="supplier-tabs density-tabs" role="tablist" aria-label="供应商分类统计"> <nav class="supplier-tabs density-tabs" role="tablist" aria-label="供应商分类统计">
<button role="tab" :aria-selected="activeCategory === item.category" v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)"><span class="density-tab-icon" aria-hidden="true">{{ ({ BOOKING: '🚢', CUSTOMS: '📋', TRUCKING: '🚚', CERTIFICATE: '📄' })[item.category] }}</span>{{ categoryLabels[item.category] }}({{ item.value }})</button> <button role="tab" :aria-selected="activeCategory === item.category" v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)"><span class="density-tab-icon" aria-hidden="true">{{ ({ BOOKING: '🚢', CUSTOMS: '📋', TRUCKING: '🚚', CERTIFICATE: '📄' })[item.category] }}</span>{{ categoryLabels[item.category] }}({{ item.value }})</button>
</nav> </nav>
@@ -134,24 +138,22 @@ onActivated(() => {
</div> </div>
</div> </div>
</template> </template>
<template #state> <template #notices>
<LoadErrorState v-if="!canView" title="暂无供应商查看权限" message="请联系管理员开通供应商查看权限。" :show-retry="false" /> <LoadErrorState v-if="!canView" title="暂无供应商查看权限" message="请联系管理员开通供应商查看权限。" :show-retry="false" />
<LoadErrorState v-else-if="loadError" title="供应商加载失败" :message="loadError.message" :loading="loading" @retry="load"> <LoadErrorState v-else-if="loadError" title="供应商加载失败" :message="loadError.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="loadError" /></template> <template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState> </LoadErrorState>
</template> </template>
<section v-if="canView" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" empty-text="暂无供应商数据" preference-key="supplier-list">
<template #toolbar-actions> <template #toolbar-actions>
<el-button v-if="access.can('platform:supplier:create')" type="primary" @click="router.push({ name: 'supplier-create' })"><Plus :size="16" />新增供应商</el-button> <el-button v-if="access.can('platform:supplier:create')" type="primary" @click="router.push({ name: 'supplier-create' })"><Plus :size="16" />新增供应商</el-button>
</template> </template>
<template #actions="{ row }"> <template #actions="{ row }"><div class="row-actions density-actions admin-text-actions">
<div class="row-actions density-actions">
<el-button data-tone="neutral" class="density-action" size="small" @click="router.push({ name: 'supplier-detail', params: { id: row.id } })">详情</el-button> <el-button data-tone="neutral" class="density-action" size="small" @click="router.push({ name: 'supplier-detail', params: { id: row.id } })">详情</el-button>
<el-button data-tone="neutral" class="density-action" v-if="access.can('platform:supplier:edit')" size="small" @click="router.push({ name: 'supplier-edit', params: { id: row.id } })">编辑</el-button> <el-button data-tone="neutral" class="density-action" v-if="access.can('platform:supplier:edit')" size="small" @click="router.push({ name: 'supplier-edit', params: { id: row.id } })">编辑</el-button>
<el-button class="density-action" v-if="access.can('platform:supplier:status')" size="small" :data-tone="row.status === 'ACTIVE' ? 'danger' : 'success'" :loading="actionId === row.id" @click="toggleStatus(supplierRow(row))">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button> <el-button class="density-action" v-if="access.can('platform:supplier:status')" size="small" :data-tone="row.status === 'ACTIVE' ? 'danger' : 'success'" :loading="actionId === row.id" @click="toggleStatus(supplierRow(row))">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div> </div></template>
</template>
<template #name="{ row }"><LineClampText :content="row.name" /></template> <template #name="{ row }"><LineClampText :content="row.name" /></template>
<template #category="{ row }">{{ categoryLabels[row.category as SupplierCategory] }}</template> <template #category="{ row }">{{ categoryLabels[row.category as SupplierCategory] }}</template>
<template #truckingScope="{ row }">{{ formatTruckingScope(supplierRow(row).truckingScope) }}</template> <template #truckingScope="{ row }">{{ formatTruckingScope(supplierRow(row).truckingScope) }}</template>
@@ -159,15 +161,14 @@ onActivated(() => {
<template #license="{ row }"><el-button data-tone="neutral" class="density-action" size="small" @click="showLicense(supplierRow(row))">查看</el-button></template> <template #license="{ row }"><el-button data-tone="neutral" class="density-action" size="small" @click="showLicense(supplierRow(row))">查看</el-button></template>
<template #status="{ row }"><StatusBadge :status="statusLabels[supplierRow(row).status] ?? '—'" /></template> <template #status="{ row }"><StatusBadge :status="statusLabels[supplierRow(row).status] ?? '—'" /></template>
<template #settlement="{ row }">{{ settlementLabels[supplierRow(row).settlementPeriod] }}</template> <template #settlement="{ row }">{{ settlementLabels[supplierRow(row).settlementPeriod] }}</template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<SupplierLicenseDialog v-model="licenseOpen" :supplier="licenseSupplier" /> <SupplierLicenseDialog v-model="licenseOpen" :supplier="licenseSupplier" />
</AdminPage> </AdminPage>
</template> </template>
<style scoped> <style scoped>
.supplier-list-top { display: grid; gap: var(--admin-space-6); }.supplier-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: #fff; }.supplier-query-toolbar { border: 0; } .supplier-list-top { display: grid; gap: var(--admin-space-6); }.supplier-query-panel { min-width: 0; }.supplier-query-toolbar { border: 0; }
@media (max-width: 720px) { .supplier-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 720px) { .supplier-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 380px) { .supplier-metrics { grid-template-columns: 1fr; } } @media (max-width: 380px) { .supplier-metrics { grid-template-columns: 1fr; } }
</style> </style>
+1 -1
View File
@@ -1,7 +1,7 @@
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
export const supplierRoutes = [ export const supplierRoutes = [
{ path: 'suppliers', name: 'supplier-list', component: () => import('@/modules/suppliers/pages/SuppliersView.vue'), meta: { title: '供应商列表', description: '维护平台公共订舱、报关、拖车和证书供应商', permission: 'platform:supplier:list' } }, { path: 'suppliers', name: 'supplier-list', component: () => import('@/modules/suppliers/pages/SuppliersView.vue'), meta: { showPageHeading: false, title: '供应商列表', description: '维护平台公共订舱、报关、拖车和证书供应商', permission: 'platform:supplier:list' } },
{ path: 'suppliers/new', name: 'supplier-create', component: () => import('@/modules/suppliers/pages/SupplierFormView.vue'), meta: { title: '新增供应商', showPageHeading: false, description: '新增平台公共供应商档案', permission: 'platform:supplier:create' } }, { path: 'suppliers/new', name: 'supplier-create', component: () => import('@/modules/suppliers/pages/SupplierFormView.vue'), meta: { title: '新增供应商', showPageHeading: false, description: '新增平台公共供应商档案', permission: 'platform:supplier:create' } },
{ path: 'suppliers/:id', name: 'supplier-detail', component: () => import('@/modules/suppliers/pages/SupplierDetailView.vue'), meta: { title: '供应商详情', showPageHeading: false, description: '查看平台公共供应商完整档案', permission: 'platform:supplier:list' } }, { path: 'suppliers/:id', name: 'supplier-detail', component: () => import('@/modules/suppliers/pages/SupplierDetailView.vue'), meta: { title: '供应商详情', showPageHeading: false, description: '查看平台公共供应商完整档案', permission: 'platform:supplier:list' } },
{ path: 'suppliers/:id/edit', name: 'supplier-edit', component: () => import('@/modules/suppliers/pages/SupplierFormView.vue'), meta: { title: '编辑供应商', showPageHeading: false, description: '编辑平台公共供应商档案', permission: 'platform:supplier:edit' } }, { path: 'suppliers/:id/edit', name: 'supplier-edit', component: () => import('@/modules/suppliers/pages/SupplierFormView.vue'), meta: { title: '编辑供应商', showPageHeading: false, description: '编辑平台公共供应商档案', permission: 'platform:supplier:edit' } },
@@ -86,7 +86,7 @@ describe('TruckAppointmentsView', () => {
it('uses the table toolbar for create and keeps a single empty state', () => { it('uses the table toolbar for create and keeps a single empty state', () => {
expect(source).toContain('<template #toolbar-actions>') expect(source).toContain('<template #toolbar-actions>')
expect(source).not.toContain('<template #toolbar-start>') expect(source).toContain('<template #toolbar-start><h1>拖车列表</h1></template>')
expect(source).not.toContain('PageToolbar') expect(source).not.toContain('PageToolbar')
expect(source).not.toContain('empty-hint') expect(source).not.toContain('empty-hint')
expect(source.match(/empty-text=/g)).toHaveLength(1) expect(source.match(/empty-text=/g)).toHaveLength(1)
@@ -117,7 +117,7 @@ describe('TruckAppointmentsView', () => {
expect(wrapper.text()).toContain('候选服务暂不可用') expect(wrapper.text()).toContain('候选服务暂不可用')
expect(wrapper.text()).toContain('req-truck') expect(wrapper.text()).toContain('req-truck')
vi.mocked(listTruckAppointments).mockResolvedValueOnce({ records: [row], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(listTruckAppointments).mockResolvedValueOnce({ records: [row], page: 1, size: 20, total: 1, pages: 1 })
await wrapper.get('button').trigger('click') await wrapper.get('.data-table__notices .load-error-state__actions button').trigger('click')
await flushPromises() await flushPromises()
expect(listTruckAppointments).toHaveBeenCalledTimes(2) expect(listTruckAppointments).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('TA-9001') expect(wrapper.text()).toContain('TA-9001')
@@ -186,11 +186,13 @@ describe('TruckAppointmentsView', () => {
expect(source).not.toContain('customerId:') expect(source).not.toContain('customerId:')
expect(source).not.toContain('customerName') expect(source).not.toContain('customerName')
}) })
it('uses bordered list actions instead of text links', () => { it('opts only the operation column into text actions and keeps column preferences', () => {
expect(source).toContain('size="small" type="primary" plain') expect(source).toContain('size="small" type="primary" plain')
expect(source).toContain('size="small" :aria-label="appointmentActionLabel(\'编辑\'') expect(source).toContain('size="small" :aria-label="appointmentActionLabel(\'编辑\'')
expect(source).not.toContain("truck-appointment:view')\" link") expect(source).not.toContain("truck-appointment:view')\" link")
expect(source).toContain(':total="total" stripe preference-key="trucking-appointments"') expect(source).toMatch(/class="[^"]*row-actions[^"]*admin-text-actions"/)
expect(source).toContain('appearance="integrated" :stripe="false"')
expect(source).toContain('preference-key="trucking-appointments"')
}) })
it('uses unified container types and a wider composite keyword field', () => { it('uses unified container types and a wider composite keyword field', () => {
@@ -64,18 +64,21 @@ function openDetail(row: TruckAppointmentResource, edit = false) { router.push({
<template> <template>
<AdminPage as="div" class="trucking-page admin-density"> <AdminPage as="div" class="trucking-page admin-density">
<ListPageLayout aria-label="拖车预约列表"> <ListPageLayout aria-label="拖车预约列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="拖车预约查询" :fields="fields" preference-key="trucking-query" @search="search" /></template>
<template v-if="loadError" #state><LoadErrorState title="拖车预约列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading"> <section :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="trucking-appointments" aria-label="拖车预约列表" empty-text="暂无符合筛选条件的拖车预约">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe preference-key="trucking-appointments" aria-label="拖车预约列表" empty-text="暂无符合筛选条件的拖车预约"> <template #toolbar-start><h1>拖车列表</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="拖车预约查询" :fields="fields" preference-key="trucking-query" @search="search" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="拖车预约列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #toolbar-actions><el-button v-if="access.can('platform:firstleg:truck-appointment:create')" type="primary" :disabled="loading" @click="router.push({ name: 'trucking-new' })"><Plus :size="16" />新增拖车</el-button></template> <template #toolbar-actions><el-button v-if="access.can('platform:firstleg:truck-appointment:create')" type="primary" :disabled="loading" @click="router.push({ name: 'trucking-new' })"><Plus :size="16" />新增拖车</el-button></template>
<template #company="{ row }">{{ row.carrierName || (row.carrierId ? '已关联承运商' : '—') }}</template> <template #company="{ row }">{{ row.carrierName || (row.carrierId ? '已关联承运商' : '—') }}</template>
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.driverPhone || '—' }}</small></template> <template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.driverPhone || '—' }}</small></template>
<template #store="{ row }">{{ row.storeName || '—' }}</template> <template #store="{ row }">{{ row.storeName || '—' }}</template>
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template> <template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
<template #actions="{ row }"><div class="table-actions density-actions"><el-button class="density-action" size="small" type="primary" plain :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)">详情</el-button><el-button class="density-action" v-if="access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)" size="small" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)">编辑</el-button></div></template> <template #actions="{ row }"><div class="table-actions density-actions row-actions admin-text-actions"><el-button class="density-action" size="small" type="primary" plain :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)">详情</el-button><el-button class="density-action" v-if="access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)" size="small" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)">编辑</el-button></div></template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
</AdminPage> </AdminPage>
</template> </template>
+1 -1
View File
@@ -5,7 +5,7 @@ export const truckingRoutes = [
path: 'trucking', path: 'trucking',
name: 'trucking', name: 'trucking',
component: () => import('./pages/TruckAppointmentsView.vue'), component: () => import('./pages/TruckAppointmentsView.vue'),
meta: { title: '拖车预约', description: '维护拖车预约及派车状态', permission: 'platform:firstleg:truck-appointment:list' }, meta: { showPageHeading: false, title: '拖车预约', description: '维护拖车预约及派车状态', permission: 'platform:firstleg:truck-appointment:list' },
}, },
{ {
path: 'trucking/new', path: 'trucking/new',
@@ -53,7 +53,7 @@ function mountView() {
AdminPage: { template: '<main><slot /></main>' }, AdminPage: { template: '<main><slot /></main>' },
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' }, ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue'], emits: ['update:modelValue', 'search', 'reset'], template: '<div data-testid="query-toolbar" />' }, QueryToolbar: { name: 'QueryToolbar', props: ['modelValue'], emits: ['update:modelValue', 'search', 'reset'], template: '<div data-testid="query-toolbar" />' },
DataTable: { name: 'DataTable', props: ['page', 'pageSize', 'data'], emits: ['update:page', 'update:pageSize', 'selection-change'], template: '<div data-testid="data-table"><slot name="toolbar-actions" /><div v-for="row in data" :key="row.orderId" class="test-row"><slot name="estimatedVolume" :row="row" /><slot name="expectedArrival" :row="row" /></div></div>' }, DataTable: { name: 'DataTable', props: ['page', 'pageSize', 'data'], emits: ['update:page', 'update:pageSize', 'selection-change'], template: '<div data-testid="data-table"><slot name="toolbar-start" /><slot name="toolbar-actions" /><slot name="filters" /><slot name="notices" /><div v-for="row in data" :key="row.orderId" class="test-row"><slot name="estimatedVolume" :row="row" /><slot name="expectedArrival" :row="row" /></div></div>' },
'el-button': { template: '<button><slot /></button>' }, 'el-button': { template: '<button><slot /></button>' },
'el-input-number': { name: 'ElInputNumber', props: ['modelValue', 'disabled'], emits: ['update:modelValue', 'change'], template: '<input class="number-input" :value="modelValue" :disabled="disabled" @change="$emit(\'change\')" />' }, 'el-input-number': { name: 'ElInputNumber', props: ['modelValue', 'disabled'], emits: ['update:modelValue', 'change'], template: '<input class="number-input" :value="modelValue" :disabled="disabled" @change="$emit(\'change\')" />' },
'el-date-picker': { name: 'ElDatePicker', props: ['modelValue', 'disabled'], emits: ['update:modelValue', 'change'], template: '<input class="date-input" :value="modelValue" :disabled="disabled" @change="$emit(\'change\')" />' }, 'el-date-picker': { name: 'ElDatePicker', props: ['modelValue', 'disabled'], emits: ['update:modelValue', 'change'], template: '<input class="date-input" :value="modelValue" :disabled="disabled" @change="$emit(\'change\')" />' },
@@ -236,12 +236,14 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain("detail.summary.internalRemark || detail.summary.latestInternalNote || '—'") expect(source).toContain("detail.summary.internalRemark || detail.summary.latestInternalNote || '—'")
}) })
it('uses ListPageLayout regions for one 16px query-to-table gap', () => { it('keeps the title, search, errors and results in the integrated list', () => {
expect(source).toContain('<ListPageLayout ariaLabel="统一订单列表">') expect(source).toContain('<ListPageLayout ariaLabel="统一订单列表">')
expect(source).toContain('<template #toolbar>') expect(source).toContain('<template #filters>')
expect(source).toContain('<template v-if="loadError" #state>') expect(source).toContain('<template #notices>')
expect(source).toContain('<template #default>') expect(source).toContain('<template #default>')
expect(source).toContain('v-if="!loadError" id="unified-orders-results" class="surface-panel table-panel table-card"') expect(source).toContain(':results-visible="!loadError"')
expect(source).toContain('<template #toolbar-start><h1>订单列表</h1></template>')
expect(source).toContain('appearance="integrated"')
expect(source).not.toContain('class="orders-load-error"') expect(source).not.toContain('class="orders-load-error"')
expect(source).not.toContain('.orders-load-error { margin-top: 0; }') expect(source).not.toContain('.orders-load-error { margin-top: 0; }')
}) })
@@ -249,7 +251,7 @@ describe('UnifiedOrdersView contract', () => {
it('counts detail in the three directly visible row actions', () => { it('counts detail in the three directly visible row actions', () => {
expect(source).toContain("{ key: 'detail', label: '详情', priority: 500 }") expect(source).toContain("{ key: 'detail', label: '详情', priority: 500 }")
expect(source).toContain('...unifiedOrderSecondaryActions(row).map') expect(source).toContain('...unifiedOrderSecondaryActions(row).map')
expect(source).toContain('<RowActions class="density-action" :actions="unifiedOrderRowActions(row as UnifiedOrderResource)"') expect(source).toContain('<RowActions text-actions class="density-action" :actions="unifiedOrderRowActions(row as UnifiedOrderResource)"')
expect(source).toContain("if (action === 'approve') return '审核'") expect(source).toContain("if (action === 'approve') return '审核'")
expect(source).not.toContain("if (action === 'approve') return '通过'") expect(source).not.toContain("if (action === 'approve') return '通过'")
}) })
@@ -2692,8 +2692,15 @@ onBeforeUnmount(() => {
<template v-else> <template v-else>
<ListPageLayout ariaLabel="统一订单列表"> <ListPageLayout ariaLabel="统一订单列表">
<template #toolbar>
<div class="orders-query-panel">
<template #default>
<div id="unified-orders-results" role="tabpanel"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table-v2" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :row-class-name="unifiedOrderRowClassName" row-key="orderId" border empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleOrderSelectionChange">
<template #toolbar-start><h1>订单列表</h1></template>
<template #filters>
<div class="orders-query-panel integrated-query-group">
<div class="status-tabs" role="tablist" aria-label="订单主状态"> <div class="status-tabs" role="tablist" aria-label="订单主状态">
<button <button
v-for="(tab, index) in statusTabs" v-for="(tab, index) in statusTabs"
@@ -2719,17 +2726,13 @@ onBeforeUnmount(() => {
</QueryToolbar> </QueryToolbar>
</div> </div>
</template> </template>
<template #notices><template v-if="loadError">
<template v-if="loadError" #state>
<LoadErrorState title="订单列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadOrders"> <LoadErrorState title="订单列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadOrders">
<template #details><ApiErrorMeta :details="loadError" /></template> <template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState> </LoadErrorState>
</template> </template></template>
<template #before-results><div v-if="coLoadError" class="coload-error" role="alert"><span>{{ coLoadError.message }}</span><ApiErrorMeta :details="coLoadError" /></div></template>
<template #default>
<div v-if="!loadError" id="unified-orders-results" class="surface-panel table-panel table-card" role="tabpanel">
<div v-if="coLoadError" class="coload-error" role="alert"><span>{{ coLoadError.message }}</span><ApiErrorMeta :details="coLoadError" /></div>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table-v2" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :row-class-name="unifiedOrderRowClassName" row-key="orderId" border stripe empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleOrderSelectionChange">
<template #toolbar-actions> <template #toolbar-actions>
<el-button v-if="can('platform:order:import')" :disabled="loading" @click="openLegacyImport"><FileUp :size="16" />导入旧系统订单</el-button> <el-button v-if="can('platform:order:import')" :disabled="loading" @click="openLegacyImport"><FileUp :size="16" />导入旧系统订单</el-button>
<el-button v-if="can('platform:order:export') && can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportOrders"><Download :size="16" />导出<span v-if="selectedOrders.length">(已选 {{ selectedOrders.length }})</span></el-button> <el-button v-if="can('platform:order:export') && can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportOrders"><Download :size="16" />导出<span v-if="selectedOrders.length">(已选 {{ selectedOrders.length }})</span></el-button>
@@ -2740,7 +2743,7 @@ onBeforeUnmount(() => {
<el-button v-if="can('platform:firstleg:inbound:view')" :loading="printingCartonLabels" :disabled="loading || printingCartonLabels || !selectedCartonLabelOrders.length" @click="printSelectedCartonLabels"><Printer :size="16" />打印箱唛<span v-if="selectedCartonLabelOrders.length">({{ selectedCartonLabelOrders.length }})</span></el-button> <el-button v-if="can('platform:firstleg:inbound:view')" :loading="printingCartonLabels" :disabled="loading || printingCartonLabels || !selectedCartonLabelOrders.length" @click="printSelectedCartonLabels"><Printer :size="16" />打印箱唛<span v-if="selectedCartonLabelOrders.length">({{ selectedCartonLabelOrders.length }})</span></el-button>
<el-button v-if="can('platform:firstleg:smart-loading:list')" type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button> <el-button v-if="can('platform:firstleg:smart-loading:list')" type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
</template> </template>
<template #actions="{ row }"><RowActions class="density-action" :actions="unifiedOrderRowActions(row as UnifiedOrderResource)" :more-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" @action="handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)" /></template> <template #actions="{ row }"><RowActions text-actions class="density-action" :actions="unifiedOrderRowActions(row as UnifiedOrderResource)" :more-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" @action="handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)" /></template>
<template #inboundCode="{ row }"> <template #inboundCode="{ row }">
<div class="inbound-relation-cell order-code-cell"> <div class="inbound-relation-cell order-code-cell">
<strong class="inbound-code-value" :title="(row as UnifiedOrderResource).inboundCode || '待生成入仓码'">{{ (row as UnifiedOrderResource).inboundCode || '待生成入仓码' }}</strong> <strong class="inbound-code-value" :title="(row as UnifiedOrderResource).inboundCode || '待生成入仓码'">{{ (row as UnifiedOrderResource).inboundCode || '待生成入仓码' }}</strong>
@@ -2807,8 +2810,7 @@ onBeforeUnmount(() => {
<template #expectedShipDate="{ row }">{{ formatDate((row as UnifiedOrderResource).expectedShipDate) }}</template> <template #expectedShipDate="{ row }">{{ formatDate((row as UnifiedOrderResource).expectedShipDate) }}</template>
<template #updatedAt="{ row }">{{ formatDate((row as UnifiedOrderResource).updatedAt) }}</template> <template #updatedAt="{ row }">{{ formatDate((row as UnifiedOrderResource).updatedAt) }}</template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template> <template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template>
</DataTable> </DataTable></div>
</div>
</template> </template>
</ListPageLayout> </ListPageLayout>
@@ -2974,7 +2976,7 @@ onBeforeUnmount(() => {
.page-intro h1 { margin: 0; color: var(--cc-text); font-size: 22px; line-height: 1.35; } .page-intro h1 { margin: 0; color: var(--cc-text); font-size: 22px; line-height: 1.35; }
.page-intro p { margin: 5px 0 0; color: var(--cc-muted); font-size: var(--admin-font-body, 13px); line-height: 1.6; } .page-intro p { margin: 5px 0 0; color: var(--cc-muted); font-size: var(--admin-font-body, 13px); line-height: 1.6; }
.page-intro :deep(.el-button) { flex: 0 0 auto; } .page-intro :deep(.el-button) { flex: 0 0 auto; }
.orders-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: var(--admin-radius, 4px); background: var(--admin-surface, #fff); } .orders-query-panel { min-width: 0; }
.status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid var(--admin-table-border, #e5e7eb); background: #f9fafb; scrollbar-width: thin; } .status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid var(--admin-table-border, #e5e7eb); background: #f9fafb; scrollbar-width: thin; }
.status-tab { position: relative; flex: 0 0 auto; min-height: var(--admin-tab-height, 38px); padding: 0 var(--admin-space-6, 16px); border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: var(--admin-font-body, 13px); cursor: pointer; } .status-tab { position: relative; flex: 0 0 auto; min-height: var(--admin-tab-height, 38px); padding: 0 var(--admin-space-6, 16px); border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: var(--admin-font-body, 13px); cursor: pointer; }
.status-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; } .status-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }
+1 -1
View File
@@ -5,7 +5,7 @@ export const unifiedOrderRoutes = [
path: 'orders', path: 'orders',
name: 'unified-orders', name: 'unified-orders',
component: () => import('./pages/UnifiedOrdersView.vue'), component: () => import('./pages/UnifiedOrdersView.vue'),
meta: { meta: { showPageHeading: false,
title: '订单列表', title: '订单列表',
description: '围绕同一订单主线跟进审核、测量、分柜、装柜和派送', description: '围绕同一订单主线跟进审核、测量、分柜、装柜和派送',
permission: 'platform:order:list', permission: 'platform:order:list',
@@ -216,7 +216,7 @@ describe('LocationsView', () => {
const wrapper = await mountView() const wrapper = await mountView()
await selectScope(wrapper) await selectScope(wrapper)
const tableToolbar = wrapper.find('.table-panel .data-table__toolbar') const tableToolbar = wrapper.find('.data-table--integrated .data-table__toolbar')
expect(tableToolbar.exists()).toBe(true) expect(tableToolbar.exists()).toBe(true)
expect(tableToolbar.text()).toContain('新增库位') expect(tableToolbar.text()).toContain('新增库位')
expect(tableToolbar.text()).not.toContain('库位列表') expect(tableToolbar.text()).not.toContain('库位列表')
@@ -242,7 +242,7 @@ describe('LocationsView', () => {
it('opens creation without a list filter and requires the warehouse inside the form', async () => { it('opens creation without a list filter and requires the warehouse inside the form', async () => {
const wrapper = await mountView() const wrapper = await mountView()
const createButton = wrapper.find('.table-panel .data-table__toolbar').findAll('button').find((button) => button.text().trim() === '新增库位') const createButton = wrapper.find('.data-table--integrated .data-table__toolbar').findAll('button').find((button) => button.text().trim() === '新增库位')
expect(createButton?.attributes('disabled')).toBeUndefined() expect(createButton?.attributes('disabled')).toBeUndefined()
await createButton!.trigger('click') await createButton!.trigger('click')
@@ -258,7 +258,7 @@ describe('LocationsView', () => {
const toolbar = wrapper.findComponent(QueryToolbar) const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { ...(toolbar.props('modelValue') as QueryModel), warehouseId: fbaWarehouse.id }) toolbar.vm.$emit('update:modelValue', { ...(toolbar.props('modelValue') as QueryModel), warehouseId: fbaWarehouse.id })
await flushPromises() await flushPromises()
const createButton = wrapper.find('.table-panel .data-table__toolbar').findAll('button').find((button) => button.text().trim() === '新增库位') const createButton = wrapper.find('.data-table--integrated .data-table__toolbar').findAll('button').find((button) => button.text().trim() === '新增库位')
await createButton!.trigger('click') await createButton!.trigger('click')
await flushPromises() await flushPromises()
@@ -483,7 +483,25 @@ onMounted(() => {
<template> <template>
<AdminPage as="div" class="location-page admin-density"> <AdminPage as="div" class="location-page admin-density">
<ListPageLayout aria-label="库位列表页面"> <ListPageLayout aria-label="库位列表页面">
<template #toolbar>
<section aria-label="库位列表区域"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
border
preference-key="locations-table"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100, 200]"
row-key="id"
aria-label="库位列表"
:empty-text="emptyDescription"
>
<template #toolbar-start><h1>库位管理</h1></template>
<template #filters>
<QueryToolbar <QueryToolbar
v-model="filters" v-model="filters"
ariaLabel="库位筛选" ariaLabel="库位筛选"
@@ -492,7 +510,7 @@ onMounted(() => {
@search="applyFilters" @search="applyFilters"
/> />
</template> </template>
<template #state> <template #notices>
<LoadErrorState <LoadErrorState
v-if="warehouseError" v-if="warehouseError"
title="仓库列表加载失败" title="仓库列表加载失败"
@@ -512,25 +530,9 @@ onMounted(() => {
/> />
</template> </template>
<section v-if="!loadError" class="surface-panel table-panel" aria-label="库位列表区域">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
border
stripe
preference-key="locations-table"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100, 200]"
row-key="id"
aria-label="库位列表"
:empty-text="emptyDescription"
>
<template #toolbar-actions><el-button v-if="access.can('platform:location:create')" type="primary" @click="openCreate"><Plus :size="16" />新增库位</el-button></template> <template #toolbar-actions><el-button v-if="access.can('platform:location:create')" type="primary" @click="openCreate"><Plus :size="16" />新增库位</el-button></template>
<template #actions="{ row }"> <template #actions="{ row }"><div class="location-actions density-actions row-actions admin-text-actions">
<div class="location-actions density-actions">
<el-button <el-button
v-if="canEdit(row as PlatformLocationResource)" v-if="canEdit(row as PlatformLocationResource)"
size="small" size="small"
@@ -554,8 +556,7 @@ onMounted(() => {
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))" :disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
@click="removeLocation(row as PlatformLocationResource)" @click="removeLocation(row as PlatformLocationResource)"
>删除</el-button> >删除</el-button>
</div> </div></template>
</template>
<template #location="{ row }"> <template #location="{ row }">
<div class="location-cell"> <div class="location-cell">
<span><MapPin :size="17" /></span> <span><MapPin :size="17" /></span>
@@ -593,8 +594,7 @@ onMounted(() => {
</el-empty> </el-empty>
</template> </template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个库位</span></template> <template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个库位</span></template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<AppDialog <AppDialog
@@ -185,7 +185,7 @@ describe('PalletsView', () => {
expect(wrapper.find('.data-table').text()).not.toContain('黄色') expect(wrapper.find('.data-table').text()).not.toContain('黄色')
expect(wrapper.find('.data-table').text()).not.toContain('标准川字托盘') expect(wrapper.find('.data-table').text()).not.toContain('标准川字托盘')
expect(wrapper.text()).not.toContain('更新时间') expect(wrapper.text()).not.toContain('更新时间')
const tableToolbar = wrapper.find('.table-panel .data-table__toolbar') const tableToolbar = wrapper.find('.data-table--integrated .data-table__toolbar')
expect(tableToolbar.exists()).toBe(true) expect(tableToolbar.exists()).toBe(true)
expect(tableToolbar.text()).toContain('新增托盘') expect(tableToolbar.text()).toContain('新增托盘')
expect(tableToolbar.text()).not.toContain('托盘列表') expect(tableToolbar.text()).not.toContain('托盘列表')
@@ -2,7 +2,7 @@
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Boxes, Plus, Save, Warehouse as WarehouseIcon } from '@lucide/vue' import { Boxes, Plus, Save } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration' import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses' import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
@@ -345,16 +345,34 @@ onMounted(() => {
<template> <template>
<AdminPage as="div" class="pallet-page admin-density"> <AdminPage as="div" class="pallet-page admin-density">
<section class="summary-grid" aria-label="托盘数量概览">
<ListPageLayout aria-label="托盘列表页面">
<section aria-label="托盘列表区域"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
border
preference-key="pallets-table"
:loading="loading || warehouseLoading"
:total="total"
row-key="id"
:empty-text="filtered ? '暂无符合筛选条件的托盘' : '暂无托盘数据'"
aria-label="托盘列表"
>
<template #before-results> <section class="summary-grid" aria-label="托盘数量概览">
<article><span>托盘总数</span><strong>{{ summary.totalCount }}</strong></article> <article><span>托盘总数</span><strong>{{ summary.totalCount }}</strong></article>
<article><span>闲置托盘</span><strong class="idle">{{ summary.idleCount }}</strong></article> <article><span>闲置托盘</span><strong class="idle">{{ summary.idleCount }}</strong></article>
<article><span>占用托盘</span><strong class="occupied">{{ summary.occupiedCount }}</strong></article> <article><span>占用托盘</span><strong class="occupied">{{ summary.occupiedCount }}</strong></article>
</section> </section></template>
<ListPageLayout aria-label="托盘列表页面"> <template #toolbar-start><h1>托盘管理</h1></template>
<template #toolbar> <template #filters>
<QueryToolbar <QueryToolbar
v-model="filters" v-model="filters"
class="surface-panel pallet-filter-toolbar" class="pallet-filter-toolbar"
ariaLabel="托盘筛选" ariaLabel="托盘筛选"
:fields="queryFields" :fields="queryFields"
preference-key="pallets-query" preference-key="pallets-query"
@@ -363,7 +381,7 @@ onMounted(() => {
@reset="resetFilters" @reset="resetFilters"
/> />
</template> </template>
<template #state> <template #notices>
<LoadErrorState <LoadErrorState
v-if="warehouseError" v-if="warehouseError"
title="仓库列表暂时无法加载" title="仓库列表暂时无法加载"
@@ -380,24 +398,9 @@ onMounted(() => {
/> />
</template> </template>
<section v-if="!loadError" class="table-panel surface-panel" aria-label="托盘列表区域">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
border
stripe
preference-key="pallets-table"
:loading="loading || warehouseLoading"
:total="total"
row-key="id"
:empty-text="filtered ? '暂无符合筛选条件的托盘' : '暂无托盘数据'"
aria-label="托盘列表"
>
<template #toolbar-actions><el-button v-if="access.can('platform:pallet:create')" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template> <template #toolbar-actions><el-button v-if="access.can('platform:pallet:create')" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }"> <template #actions="{ row }"><div class="pallet-row-actions density-actions row-actions admin-text-actions">
<div class="pallet-row-actions density-actions">
<el-button v-if="access.can('platform:pallet:edit') && isDomesticWarehouse((row as PlatformPalletResource).warehouseId)" class="density-action" size="small" title="编辑托盘" @click="openEdit(row as PlatformPalletResource)">编辑</el-button> <el-button v-if="access.can('platform:pallet:edit') && isDomesticWarehouse((row as PlatformPalletResource).warehouseId)" class="density-action" size="small" title="编辑托盘" @click="openEdit(row as PlatformPalletResource)">编辑</el-button>
<el-button <el-button
v-if="access.can('platform:pallet:delete')" v-if="access.can('platform:pallet:delete')"
@@ -407,8 +410,7 @@ onMounted(() => {
:loading="deletingIds.has(String((row as PlatformPalletResource).id))" :loading="deletingIds.has(String((row as PlatformPalletResource).id))"
@click="removePallet(row as PlatformPalletResource)" @click="removePallet(row as PlatformPalletResource)"
>删除</el-button> >删除</el-button>
</div> </div></template>
</template>
<template #pallet="{ row }"> <template #pallet="{ row }">
<div class="pallet-cell"><span><Boxes :size="18" /></span><strong>{{ (row as PlatformPalletResource).palletCode }}</strong></div> <div class="pallet-cell"><span><Boxes :size="18" /></span><strong>{{ (row as PlatformPalletResource).palletCode }}</strong></div>
</template> </template>
@@ -419,8 +421,7 @@ onMounted(() => {
<template #weight="{ row }"><strong>{{ formatWeight((row as PlatformPalletResource).weightKg) }}</strong></template> <template #weight="{ row }"><strong>{{ formatWeight((row as PlatformPalletResource).weightKg) }}</strong></template>
<template #remark="{ row }"><span class="remark-text">{{ (row as PlatformPalletResource).remark || '—' }}</span></template> <template #remark="{ row }"><span class="remark-text">{{ (row as PlatformPalletResource).remark || '—' }}</span></template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个托盘</span></template> <template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个托盘</span></template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorTitle" width="min(560px, calc(100vw - 28px))" class="pallet-editor-dialog admin-density-dialog" :close-on-click-modal="false" :confirm-loading="saving" :show-confirm="false" destroy-on-close> <AppDialog v-model="editorOpen" :title="editorTitle" width="min(560px, calc(100vw - 28px))" class="pallet-editor-dialog admin-density-dialog" :close-on-click-modal="false" :confirm-loading="saving" :show-confirm="false" destroy-on-close>
+2 -2
View File
@@ -5,7 +5,7 @@ export const warehouseResourceRoutes = [
path: 'master-data/locations', path: 'master-data/locations',
name: 'locations', name: 'locations',
component: () => import('./locations/pages/LocationsView.vue'), component: () => import('./locations/pages/LocationsView.vue'),
meta: { meta: { showPageHeading: false,
title: '库位管理', title: '库位管理',
description: '维护平台仓库库位和可用状态', description: '维护平台仓库库位和可用状态',
permission: 'platform:location:list', permission: 'platform:location:list',
@@ -15,7 +15,7 @@ export const warehouseResourceRoutes = [
path: 'master-data/pallets', path: 'master-data/pallets',
name: 'pallets', name: 'pallets',
component: () => import('./pallets/pages/PalletsView.vue'), component: () => import('./pallets/pages/PalletsView.vue'),
meta: { meta: { showPageHeading: false,
title: '托盘管理', title: '托盘管理',
description: '维护平台仓库托盘和可用容积', description: '维护平台仓库托盘和可用容积',
permission: 'platform:pallet:list', permission: 'platform:pallet:list',
+22 -20
View File
@@ -2,7 +2,7 @@
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus' import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ChevronDown, Download, FileSpreadsheet, FileUp, MapPin, Plus, Save, Warehouse as WarehouseIcon } from '@lucide/vue' import { ChevronDown, Download, FileSpreadsheet, FileUp, MapPin, Plus, Save } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -692,27 +692,15 @@ onMounted(() => void loadWarehouses())
<template> <template>
<AdminPage class="admin-density" as="div"> <AdminPage class="admin-density" as="div">
<ListPageLayout aria-label="仓库列表页面"> <ListPageLayout aria-label="仓库列表页面">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" :loading="loading" preference-key="warehouses-query" @search="queryWarehouses" />
</template>
<template v-if="loadError" #state>
<LoadErrorState
title="仓库数据加载失败"
:message="loadErrorDescription"
:loading="loading"
retry-text="重新查询"
@retry="loadWarehouses"
/>
</template>
<section v-if="!loadError" class="surface-panel table-panel" aria-live="polite">
<DataTable
<section aria-live="polite"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page" v-model:page="page"
v-model:page-size="pageSize" v-model:page-size="pageSize"
:data="rows" :data="rows"
:columns="warehouseColumns" :columns="warehouseColumns"
border border
stripe
preference-key="warehouses-table" preference-key="warehouses-table"
:loading="loading" :loading="loading"
:total="total" :total="total"
@@ -721,6 +709,21 @@ onMounted(() => void loadWarehouses())
aria-label="公共仓库列表" aria-label="公共仓库列表"
:empty-text="loaded ? '暂无符合筛选条件的仓库' : '仓库数据暂不可用,请重新加载'" :empty-text="loaded ? '暂无符合筛选条件的仓库' : '仓库数据暂不可用,请重新加载'"
> >
<template #toolbar-start><h1>仓库管理</h1></template>
<template #filters>
<QueryToolbar v-model="filters" ariaLabel="仓库筛选" :fields="queryFields" :loading="loading" preference-key="warehouses-query" @search="queryWarehouses" />
</template>
<template #notices><template v-if="loadError">
<LoadErrorState
title="仓库数据加载失败"
:message="loadErrorDescription"
:loading="loading"
retry-text="重新查询"
@retry="loadWarehouses"
/>
</template></template>
<template #toolbar-actions> <template #toolbar-actions>
<div v-if="access.can('platform:warehouse:create')" class="warehouse-primary-actions"> <div v-if="access.can('platform:warehouse:create')" class="warehouse-primary-actions">
<el-button :disabled="!warehouseReady" @click="openImport"><FileUp :size="16" />导入仓库</el-button> <el-button :disabled="!warehouseReady" @click="openImport"><FileUp :size="16" />导入仓库</el-button>
@@ -730,7 +733,7 @@ onMounted(() => void loadWarehouses())
</el-dropdown> </el-dropdown>
</div> </div>
</template> </template>
<template #actions="{ row }"> <template #actions="{ row }"><div class="row-actions density-actions admin-text-actions">
<div v-if="access.can('platform:warehouse:edit') || access.can('platform:warehouse:status')" class="table-actions density-actions"> <div v-if="access.can('platform:warehouse:edit') || access.can('platform:warehouse:status')" class="table-actions density-actions">
<el-button data-tone="neutral" class="density-action" v-if="access.can('platform:warehouse:edit')" size="small" :disabled="!warehouseReady || editLoading" @click="openEdit(row as WarehouseRow)">编辑</el-button> <el-button data-tone="neutral" class="density-action" v-if="access.can('platform:warehouse:edit')" size="small" :disabled="!warehouseReady || editLoading" @click="openEdit(row as WarehouseRow)">编辑</el-button>
<el-button class="density-action" <el-button class="density-action"
@@ -744,7 +747,7 @@ onMounted(() => void loadWarehouses())
>{{ (row as WarehouseRow).status === 'ACTIVE' ? '停用' : '启用' }}</el-button> >{{ (row as WarehouseRow).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div> </div>
<span v-else class="warehouse-subline">仅查看</span> <span v-else class="warehouse-subline">仅查看</span>
</template> </div></template>
<template #warehouse="{ row }"><div class="warehouse-cell"><div><strong>{{ (row as WarehouseRow).name }}</strong></div></div></template> <template #warehouse="{ row }"><div class="warehouse-cell"><div><strong>{{ (row as WarehouseRow).name }}</strong></div></div></template>
<template #warehouseType="{ row }"><el-tag effect="plain">{{ warehouseTypeLabels[(row as WarehouseRow).warehouseType] }}</el-tag></template> <template #warehouseType="{ row }"><el-tag effect="plain">{{ warehouseTypeLabels[(row as WarehouseRow).warehouseType] }}</el-tag></template>
<template #countryCode="{ row }">{{ formatCountryDisplay((row as WarehouseRow).countryCode) }}</template> <template #countryCode="{ row }">{{ formatCountryDisplay((row as WarehouseRow).countryCode) }}</template>
@@ -752,8 +755,7 @@ onMounted(() => void loadWarehouses())
<template #contact="{ row }"><strong>{{ (row as WarehouseRow).contactName || '-' }}</strong><span class="warehouse-subline">{{ (row as WarehouseRow).contactPhone || '-' }}</span></template> <template #contact="{ row }"><strong>{{ (row as WarehouseRow).contactName || '-' }}</strong><span class="warehouse-subline">{{ (row as WarehouseRow).contactPhone || '-' }}</span></template>
<template #status="{ row }"><el-tag :type="(row as WarehouseRow).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as WarehouseRow).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template> <template #status="{ row }"><el-tag :type="(row as WarehouseRow).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as WarehouseRow).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
<template #pagination-left="{ total: tableTotal }">共 {{ tableTotal }} 个公共仓库</template> <template #pagination-left="{ total: tableTotal }">共 {{ tableTotal }} 个公共仓库</template>
</DataTable> </DataTable></section>
</section>
</ListPageLayout> </ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="warehouse-editor-dialog admin-density-dialog" :close-on-click-modal="false" :confirm-loading="saving" :show-confirm="false" destroy-on-close> <AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="warehouse-editor-dialog admin-density-dialog" :close-on-click-modal="false" :confirm-loading="saving" :show-confirm="false" destroy-on-close>
+1 -1
View File
@@ -5,6 +5,6 @@ export const warehouseRoutes = [
path: 'warehouses', path: 'warehouses',
name: 'warehouses', name: 'warehouses',
component: () => import('@/modules/warehouses/pages/WarehousesView.vue'), component: () => import('@/modules/warehouses/pages/WarehousesView.vue'),
meta: { title: '仓库管理', description: '统一维护国内收货仓、FBA 仓和海外仓', permission: 'platform:warehouse:list' }, meta: { showPageHeading: false, title: '仓库管理', description: '统一维护国内收货仓、FBA 仓和海外仓', permission: 'platform:warehouse:list' },
}, },
] satisfies RouteRecordRaw[] ] satisfies RouteRecordRaw[]

Some files were not shown because too many files have changed in this diff Show More