Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
28 changed files with 1100 additions and 162 deletions
Showing only changes of commit 8695eee8c8 - Show all commits
+2
View File
@@ -16,11 +16,13 @@
"axios": "^1.18.1",
"element-plus": "^2.14.3",
"pinia": "^4.0.2",
"sortablejs": "^1.15.7",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@types/node": "^24.10.0",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/test-utils": "^2.4.11",
"jsdom": "^29.1.1",
+16
View File
@@ -23,6 +23,9 @@ importers:
pinia:
specifier: ^4.0.2
version: 4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3))
sortablejs:
specifier: ^1.15.7
version: 1.15.7
vue:
specifier: ^3.5.40
version: 3.5.40(typescript@5.9.3)
@@ -33,6 +36,9 @@ importers:
'@types/node':
specifier: ^24.10.0
version: 24.13.3
'@types/sortablejs':
specifier: ^1.15.9
version: 1.15.9
'@vitejs/plugin-vue':
specifier: ^6.0.8
version: 6.0.8(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
@@ -361,6 +367,9 @@ packages:
'@types/node@24.13.3':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@types/sortablejs@1.15.9':
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
@@ -1073,6 +1082,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
sortablejs@1.15.7:
resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1638,6 +1650,8 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/sortablejs@1.15.9': {}
'@types/web-bluetooth@0.0.21': {}
'@vitejs/plugin-vue@6.0.8(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))':
@@ -2360,6 +2374,8 @@ snapshots:
signal-exit@4.1.0: {}
sortablejs@1.15.7: {}
source-map-js@1.2.1: {}
stackback@0.0.2: {}
+5 -2
View File
@@ -1,4 +1,4 @@
import { createApp } from 'vue'
import { computed, createApp } from 'vue'
import { createPinia } from 'pinia'
import { ElMessage } from 'element-plus'
import 'element-plus/es/components/message/style/css'
@@ -11,6 +11,7 @@ import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
import { useAuthStore } from '@/core/auth/auth'
import { registerAuthSessionExpiredHandler } from '@/shared/api/http'
import { UI_PREFERENCE_SCOPE } from '@/shared/utils/uiLayoutPreference'
import './styles/index.css'
const pinia = createPinia()
@@ -41,4 +42,6 @@ window.addEventListener('beforeunload', (event) => {
event.returnValue = ''
})
createApp(App).use(pinia).use(router).mount('#app')
const app = createApp(App)
app.provide(UI_PREFERENCE_SCOPE, computed(() => auth.user?.id || 'anonymous'))
app.use(pinia).use(router).mount('#app')
@@ -248,6 +248,7 @@ onMounted(loadRules)
v-model:page-size="pageSize"
:data="pagedRules"
:columns="columns"
preference-key="customer-billing-rules-table"
:loading="loading"
:total="rules.length"
:page-sizes="[10, 20, 50]"
+11 -11
View File
@@ -90,8 +90,8 @@ const qualificationPreviewKind = ref<'image' | 'pdf' | ''>('')
const filters = ref<CustomerFilters>({ keyword: '', status: '' })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '公司名称、客户ID或联系人' },
{ key: 'status', type: 'select', placeholder: '全部状态', options: [
{ key: 'keyword', type: 'input', label: '客户关键词', placeholder: '公司名称、客户ID或联系人' },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [
{ label: '待完善', value: 'PROFILE_REQUIRED' }, { label: '待审核', value: 'PENDING' },
{ label: '启用', value: 'ACTIVE' }, { label: '已驳回', value: 'REJECTED' },
{ label: '已停用', value: 'DISABLED' },
@@ -155,16 +155,16 @@ const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
const customerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', minWidth: 190, slotName: 'actions' },
{ prop: 'tenantCode', label: '客户ID', minWidth: 170 },
{ key: 'tenantCode', prop: 'tenantCode', label: '客户ID', minWidth: 170 },
{ key: 'company', label: '公司名称', minWidth: 230, slotName: 'company' },
{ prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', minWidth: 205, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) },
{ prop: 'contactName', label: '联系人', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.contactName) },
{ prop: 'contactPhone', label: '电话', minWidth: 150, formatter: (row: PlatformTenantResource) => displayText(row.contactPhone) },
{ prop: 'salesOwnerName', label: '销售人员', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.salesOwnerName) },
{ prop: 'paymentTermDays', label: '账期(天)', width: 105, formatter: (row: PlatformTenantResource) => row.paymentTermDays ?? 0 },
{ key: 'unifiedSocialCreditCode', prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', minWidth: 205, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) },
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.contactName) },
{ key: 'contactPhone', prop: 'contactPhone', label: '电话', minWidth: 150, formatter: (row: PlatformTenantResource) => displayText(row.contactPhone) },
{ key: 'salesOwnerName', prop: 'salesOwnerName', label: '销售人员', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.salesOwnerName) },
{ key: 'paymentTermDays', prop: 'paymentTermDays', label: '账期(天)', width: 105, formatter: (row: PlatformTenantResource) => row.paymentTermDays ?? 0 },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'orderCount', label: '运单数', width: 100, slotName: 'orderCount' },
{ prop: 'createdAt', label: '入驻时间', minWidth: 165, formatter: (row: PlatformTenantResource) => formatDate(row.createdAt) },
{ key: 'createdAt', prop: 'createdAt', label: '入驻时间', minWidth: 165, formatter: (row: PlatformTenantResource) => formatDate(row.createdAt) },
]
let loadSequence = 0
@@ -666,12 +666,12 @@ onMounted(async () => {
<template>
<div class="business-page customers-page">
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户筛选" :fields="queryFields" @search="applyFilters" />
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户筛选" :fields="queryFields" preference-key="customers-query" @search="applyFilters" />
<section v-if="loadError" class="surface-panel customers-error" role="alert"><div><strong>客户列表暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" :loading="loading" @click="loadCustomers">重新加载</el-button></section>
<section v-else class="surface-panel table-panel" :aria-busy="loading">
<div class="customers-table-header"><div class="customers-table-heading"><h2>客户列表</h2><span>共 {{ result.total }} 个客户</span></div><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"><Download :size="16" />导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增客户</el-button></div></div>
<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" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户">
<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="暂无符合筛选条件的客户">
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:customer:view')" link type="primary" @click="showDetail(row as PlatformTenantResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:customer:view') && access.can('platform:customer:edit')" link type="primary" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="15" />编辑</el-button><el-button v-if="pendingReviewByTenantId.has(String((row as PlatformTenantResource).id)) && access.can('platform:customer:approve')" link type="success" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, 'approve')"><ShieldCheck :size="15" />通过</el-button><el-button v-if="pendingReviewByTenantId.has(String((row as PlatformTenantResource).id)) && access.can('platform:customer:reject')" link type="danger" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, 'reject')"><ShieldX :size="15" />驳回</el-button><el-button v-if="access.can('platform:customer:status') && ['ACTIVE','DISABLED'].includes((row as PlatformTenantResource).status)" link :type="(row as PlatformTenantResource).status === 'ACTIVE' ? 'danger' : 'success'" :loading="rowActionId === String((row as PlatformTenantResource).id)" @click="toggleStatus(row as PlatformTenantResource)"><Power :size="15" />{{ (row as PlatformTenantResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button></div></template>
<template #company="{ row }"><div class="customer-company-cell"><span><Building2 :size="18" /></span><strong>{{ (row as PlatformTenantResource).tenantName }}</strong></div></template>
<template #status="{ row }"><el-tag :type="statusMeta((row as PlatformTenantResource).status).type" effect="light">{{ statusMeta((row as PlatformTenantResource).status).label }}</el-tag></template>
@@ -43,24 +43,24 @@ let loadRequestId = 0
let detailRequestId = 0
const queryFields: QueryField[] = [
{ key: 'exportType', type: 'select', placeholder: '全部任务类型', options: [
{ key: 'exportType', type: 'select', label: '任务类型', placeholder: '全部任务类型', options: [
{ label: '客户列表', value: 'CUSTOMER_LIST' },
] },
{ key: 'status', type: 'select', placeholder: '全部任务状态', options: [
{ key: 'status', type: 'select', label: '任务状态', placeholder: '全部任务状态', options: [
{ label: '等待处理', value: 'PENDING' }, { label: '处理中', value: 'RUNNING' },
{ label: '已完成', value: 'SUCCEEDED' }, { label: '失败', value: 'FAILED' },
{ label: '已过期', value: 'EXPIRED' },
] },
{ key: 'requestedRange', type: 'daterange', placeholder: '请选择申请日期' },
{ key: 'requestedRange', type: 'daterange', label: '申请日期', placeholder: '请选择申请日期' },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ prop: 'taskName', label: '任务名称', minWidth: 180, showOverflowTooltip: true },
{ key: 'taskName', prop: 'taskName', label: '任务名称', minWidth: 180, showOverflowTooltip: true },
{ key: 'type', label: '任务类型', width: 120, slotName: 'type' },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'progress', label: '数据量', width: 120, slotName: 'progress' },
{ prop: 'format', label: '格式', width: 80 },
{ key: 'format', prop: 'format', label: '格式', width: 80 },
{ key: 'requester', label: '申请人', minWidth: 130, slotName: 'requester' },
{ key: 'requestedAt', label: '申请时间', width: 170, slotName: 'requestedAt' },
{ key: 'completedAt', label: '完成时间', width: 170, slotName: 'completedAt' },
@@ -233,7 +233,7 @@ onBeforeUnmount(() => {
<template>
<div class="business-page download-center-page">
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="下载任务筛选" :fields="queryFields" @search="applyFilters" />
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="下载任务筛选" :fields="queryFields" preference-key="downloads-query" @search="applyFilters" />
<section v-if="loadError" class="surface-panel download-error" role="alert">
<div><strong>下载任务暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div>
@@ -249,7 +249,7 @@ onBeforeUnmount(() => {
<span>自动刷新暂时失败,正在保留当前任务并继续重试:{{ syncWarning.message }}</span>
<ApiErrorMeta :details="syncWarning" />
</div>
<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="暂无符合筛选条件的下载任务" aria-label="下载任务列表">
<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 #actions="{ row }"><div class="table-actions">
<el-button v-if="(row as PlatformExportTask)?.id" link type="primary" @click="openDetail(row as PlatformExportTask)"><Eye :size="15" />详情</el-button>
<el-button v-if="(row as PlatformExportTask).status === 'SUCCEEDED'" link type="primary" :loading="downloadingId === (row as PlatformExportTask).id" @click="downloadTask(row as PlatformExportTask)"><Download :size="15" />下载</el-button>
@@ -97,17 +97,17 @@ const { captureBaseline } = useUnsavedWorkRegistration({
const filters = ref<RateFilters>({ rateCode: '', destinationCode: '', channelCode: '', chargeUnit: '', status: '' })
const appliedFilters = ref<RateFilters>({ ...filters.value })
const queryFields = computed<QueryField[]>(() => [
{ key: 'rateCode', type: 'input', width: 220, placeholder: '价格编码', props: { 'aria-label': '价格编码' } },
{ key: 'destinationCode', type: 'input', width: 220, placeholder: 'FBA 目的仓编码', props: { 'aria-label': 'FBA 目的仓编码' } },
{ key: 'channelCode', type: 'input', width: 220, placeholder: '渠道编码', props: { 'aria-label': '渠道编码' } },
{ key: 'rateCode', type: 'input', width: 220, label: '价格编码', placeholder: '价格编码', props: { 'aria-label': '价格编码' } },
{ key: 'destinationCode', type: 'input', width: 220, label: 'FBA 目的仓编码', placeholder: 'FBA 目的仓编码', props: { 'aria-label': 'FBA 目的仓编码' } },
{ key: 'channelCode', type: 'input', width: 220, label: '渠道编码', placeholder: '渠道编码', props: { 'aria-label': '渠道编码' } },
{
key: 'chargeUnit', type: 'select', width: 180, placeholder: '全部计费单位', props: { 'aria-label': '计费单位' }, options: [
key: 'chargeUnit', type: 'select', width: 180, label: '计费单位', placeholder: '全部计费单位', props: { 'aria-label': '计费单位' }, options: [
{ label: 'KG', value: 'KG' },
{ label: 'CBM', value: 'CBM' },
],
},
{
key: 'status', type: 'select', width: 160, placeholder: '全部状态', props: { 'aria-label': '价格状态' }, options: [
key: 'status', type: 'select', width: 160, label: '价格状态', placeholder: '全部状态', props: { 'aria-label': '价格状态' }, options: [
{ label: '生效中', value: 'ACTIVE' },
{ label: '已停用', value: 'INACTIVE' },
],
@@ -403,6 +403,7 @@ onMounted(() => {
v-model="filters"
ariaLabel="船司 FBA 价格筛选"
:fields="queryFields"
preference-key="carrier-rates-query"
@search="applyQuery"
@reset="resetQuery"
/>
@@ -436,6 +437,7 @@ onMounted(() => {
v-model:page-size="pageSize"
:data="pagedRows"
:columns="columns"
preference-key="carrier-rates-table"
:loading="loading"
:total="filteredRows.length"
:page-sizes="[10, 20, 50]"
@@ -65,6 +65,7 @@ const queryFields = computed<QueryField[]>(() => [
{
key: 'tenantId',
type: 'select',
label: '企业客户',
width: 160,
placeholder: tenantLoadError.value ? '企业列表加载失败' : '全部企业',
props: { 'aria-label': '企业客户', filterable: true, disabled: Boolean(tenantLoadError.value) },
@@ -73,6 +74,7 @@ const queryFields = computed<QueryField[]>(() => [
{
key: 'status',
type: 'select',
label: '订单状态',
width: 120,
placeholder: '全部状态',
props: { 'aria-label': '订单状态' },
@@ -87,8 +89,8 @@ const queryFields = computed<QueryField[]>(() => [
{ value: 'REJECTED', label: '已驳回(旧契约)' },
],
},
{ key: 'keyword', type: 'input', placeholder: '订单号或发货客户名称', props: { 'aria-label': '订单关键词' } },
{ key: 'dateRange', type: 'daterange', props: { 'aria-label': '订单创建日期' } },
{ key: 'keyword', type: 'input', label: '订单关键词', placeholder: '订单号或发货客户名称', props: { 'aria-label': '订单关键词' } },
{ key: 'dateRange', type: 'daterange', label: '订单创建日期', props: { 'aria-label': '订单创建日期' } },
])
const orderColumns: DataTableColumn[] = [
@@ -505,6 +507,7 @@ onMounted(() => void loadTenants())
v-model="filters"
ariaLabel="头程订单筛选"
:fields="queryFields"
preference-key="first-leg-orders-query"
search-text="查询"
:search-on-reset="false"
@search="searchOrders"
@@ -527,6 +530,7 @@ onMounted(() => void loadTenants())
v-model:page-size="pageSize"
:data="orders"
:columns="orderColumns"
preference-key="first-leg-orders-table"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100]"
+6 -3
View File
@@ -43,12 +43,14 @@ const queryFields = computed<QueryField[]>(() => [
{
key: 'keyword',
type: 'input',
label: '用户关键词',
placeholder: '姓名、手机号、邮箱或员工编号',
props: { maxlength: 128, 'aria-label': '用户关键词' },
},
{
key: 'status',
type: 'select',
label: '状态',
placeholder: '全部状态',
props: { 'aria-label': '用户状态' },
options: [
@@ -116,8 +118,8 @@ const formRules = computed<FormRules>(() => ({
const userColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ prop: 'username', label: '用户名', minWidth: 145 },
{ prop: 'displayName', label: '姓名', minWidth: 130 },
{ key: 'username', prop: 'username', label: '用户名', minWidth: 145 },
{ key: 'displayName', prop: 'displayName', label: '姓名', minWidth: 130 },
{ key: 'roles', label: '角色', minWidth: 180, slotName: 'roles' },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
{ key: 'lastLoginAt', label: '最后登录', minWidth: 170, slotName: 'lastLoginAt' },
@@ -306,6 +308,7 @@ onMounted(loadInitialData)
class="surface-panel"
ariaLabel="用户筛选"
:fields="queryFields"
preference-key="internal-users-query"
@search="applyFilters"
/>
@@ -318,7 +321,7 @@ onMounted(loadInitialData)
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button>
</header>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="users" :columns="userColumns"
<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 #actions="{ row }">
<div class="table-actions">
+7 -7
View File
@@ -34,9 +34,9 @@ type PermissionFilters = QueryModel & { keyword: string; type: string; status: s
const filters = ref<PermissionFilters>({ keyword: '', type: '', status: '' })
const appliedFilters = ref<PermissionFilters>({ ...filters.value })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '权限名称、编码或路径' },
{ key: 'type', type: 'select', placeholder: '全部类型', options: [{ label: '菜单', value: '菜单' }, { label: '按钮', value: '按钮' }] },
{ key: 'status', type: 'select', placeholder: '全部状态', options: [{ label: '启用', value: '启用' }, { label: '停用', value: '停用' }] },
{ key: 'keyword', type: 'input', label: '权限关键词', placeholder: '权限名称、编码或路径' },
{ key: 'type', type: 'select', label: '类型', placeholder: '全部类型', options: [{ label: '菜单', value: '菜单' }, { label: '按钮', value: '按钮' }] },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '启用', value: '启用' }, { label: '停用', value: '停用' }] },
]
const expandedRowKeys = ref<string[]>([])
const tableRenderKey = ref(0)
@@ -83,9 +83,9 @@ const permissionColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
{ key: 'name', label: '权限名称', minWidth: 260, slotName: 'name' },
{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code' },
{ prop: 'type', label: '类型', width: 80 },
{ key: 'type', prop: 'type', label: '类型', width: 80 },
{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' },
{ prop: 'sort', label: '排序', width: 72, align: 'right' },
{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' },
{ key: 'status', label: '状态', width: 88, slotName: 'status' },
]
@@ -232,11 +232,11 @@ onMounted(loadPermissions)
<template>
<div class="business-page">
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div><el-button link type="danger" @click="loadPermissions">重新加载</el-button></template></el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="权限筛选" :fields="queryFields" @search="applyFilters" />
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="权限筛选" :fields="queryFields" preference-key="permissions-query" @search="applyFilters" />
<section class="surface-panel table-panel">
<header class="panel-heading"><div><h2>权限资源树 <span class="secondary-text">共 {{ permissions.length }} 项</span></h2><p>默认展示目录与菜单,展开菜单后查看按钮权限;接口权限将在服务端契约确定后补充。</p></div><div class="permission-tree-heading-actions"><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></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></div></header>
<DataTable :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" :loading="loading" :total="treeRows.length" row-key="id" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
<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" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
<template #actions="{ row }"><div v-if="canCreate || canEdit || canChangeStatus || canDelete" class="table-actions"><el-button v-if="canCreate && (row as Permission).type === '菜单'" link type="primary" @click="openCreate((row as Permission).id)"><Plus :size="15" />添加下级</el-button><el-button v-if="canEdit" link type="primary" :disabled="(row as Permission).builtIn || rowActionId === (row as Permission).id" @click="openEdit(row as Permission)"><Pencil :size="15" />编辑</el-button><el-button v-if="canChangeStatus" link :type="(row as Permission).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Permission).id" :disabled="(row as Permission).builtIn" @click="toggleStatus(row as Permission)">{{ (row as Permission).status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="canDelete" link type="danger" :disabled="(row as Permission).builtIn || rowActionId === (row as Permission).id" @click="removePermission(row as Permission)"><Trash2 :size="15" />删除</el-button><span v-if="(row as Permission).builtIn" class="secondary-text">系统内置</span></div><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="(row as Permission).type === '目录' || (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>
+6 -6
View File
@@ -46,8 +46,8 @@ type RoleFilters = QueryModel & { keyword: string; status: string }
const filters = ref<RoleFilters>({ keyword: '', status: '' })
const appliedFilters = ref<RoleFilters>({ ...filters.value })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '角色名称、编码或描述' },
{ key: 'status', type: 'select', placeholder: '全部状态', options: [{ label: '启用', value: '启用' }, { label: '停用', value: '停用' }] },
{ key: 'keyword', type: 'input', label: '角色关键词', placeholder: '角色名称、编码或描述' },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '启用', value: '启用' }, { label: '停用', value: '停用' }] },
]
const emptyForm = (): Omit<Role, 'userCount'> => ({ id: '', name: '', code: '', description: '', dataScope: '本部门数据', status: '启用', permissionIds: [], builtIn: false })
const form = reactive(emptyForm())
@@ -76,9 +76,9 @@ const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pag
const roleColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 235, fixed: 'left', slotName: 'actions' },
{ key: 'role', label: '角色', minWidth: 210, slotName: 'role' },
{ prop: 'description', label: '说明', minWidth: 240, showOverflowTooltip: true },
{ key: 'description', prop: 'description', label: '说明', minWidth: 240, showOverflowTooltip: true },
{ key: 'permissionSummary', label: '授权范围', minWidth: 170, slotName: 'permissionSummary' },
{ prop: 'userCount', label: '用户数', width: 86, align: 'right' },
{ key: 'userCount', prop: 'userCount', label: '用户数', width: 86, align: 'right' },
{ key: 'roleType', label: '类型', width: 95, slotName: 'roleType' },
{ key: 'status', label: '状态', width: 88, slotName: 'status' },
]
@@ -233,11 +233,11 @@ onMounted(loadRoles)
<template>
<div class="business-page">
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div><el-button link type="danger" @click="loadRoles">重新加载</el-button></template></el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="角色筛选" :fields="queryFields" @search="applyFilters" />
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="角色筛选" :fields="queryFields" preference-key="roles-query" @search="applyFilters" />
<section class="surface-panel table-panel">
<header class="panel-heading"><div><h2>角色与授权 <span class="secondary-text">共 {{ filteredRows.length }} 条</span></h2><p>角色负责聚合菜单、操作和数据范围,内部用户可分配多个角色。</p></div><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></header>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" :loading="loading" :total="filteredRows.length" row-key="id" empty-text="没有符合条件的角色">
<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" empty-text="没有符合条件的角色">
<template #actions="{ row }"><div v-if="canEdit || canGrant || access.can('platform:role:status') || access.can('platform:role:delete')" class="table-actions"><el-button v-if="canEdit || canGrant" link type="primary" :disabled="rowActionId === (row as Role).id || (row as Role).builtIn" @click="openEdit(row as Role)"><Pencil :size="15" />配置</el-button><el-button v-if="access.can('platform:role:status')" link :type="(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 v-if="access.can('platform:role:delete')" link type="danger" :disabled="(row as Role).builtIn || (row as Role).userCount > 0 || rowActionId === (row as Role).id" @click="removeRole(row as Role)"><Trash2 :size="15" />删除</el-button></div><span v-else class="secondary-text">仅查看</span></template>
<template #role="{ row }"><div class="role-cell"><span><ShieldCheck :size="18" /></span><div><strong>{{ (row as Role).name }}</strong><small>{{ (row as Role).code }}</small></div></div></template>
<template #permissionSummary="{ row }"><div class="primary-cell"><strong>{{ permissionSummary(row as Role) }}</strong><span>平台 IAM 权限</span></div></template>
@@ -73,6 +73,7 @@ const allQueryFields = computed<QueryField[]>(() => [
{
key: 'customerId',
type: 'select',
label: '所属企业',
width: 260,
placeholder: customerLoadError.value ? '所属企业加载失败' : customersLoading.value ? '正在加载客户' : '全部客户',
options: customers.value.map((customer) => ({
@@ -81,29 +82,29 @@ const allQueryFields = computed<QueryField[]>(() => [
})),
props: { 'aria-label': '所属企业', loading: customersLoading.value, filterable: true, disabled: Boolean(customerLoadError.value) || customers.value.length === 0 },
},
{ key: 'warehouseId', type: 'select', width: 180, placeholder: warehousesUnavailable.value ? '定位仓库加载失败' : '定位仓库', options: warehouseOptions.value, props: { 'aria-label': '定位仓库', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
{ key: 'deliveryWarehouseId', type: 'select', width: 180, placeholder: warehousesUnavailable.value ? '交货仓加载失败' : '交货仓', options: warehouseOptions.value, props: { 'aria-label': '交货仓', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
{ key: 'inboundCode', type: 'input', width: 220, placeholder: '入仓码' },
{ key: 'orderNo', type: 'input', width: 220, placeholder: '头程订单号' },
{ key: 'processStatus', type: 'select', width: 160, placeholder: '流程状态', options: [
{ key: 'warehouseId', type: 'select', width: 180, label: '定位仓库', placeholder: warehousesUnavailable.value ? '定位仓库加载失败' : '定位仓库', options: warehouseOptions.value, props: { 'aria-label': '定位仓库', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
{ key: 'deliveryWarehouseId', type: 'select', width: 180, label: '交货仓', placeholder: warehousesUnavailable.value ? '交货仓加载失败' : '交货仓', options: warehouseOptions.value, props: { 'aria-label': '交货仓', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
{ key: 'inboundCode', type: 'input', width: 220, label: '入仓码', placeholder: '入仓码' },
{ key: 'orderNo', type: 'input', width: 220, label: '头程订单号', placeholder: '头程订单号' },
{ key: 'processStatus', type: 'select', width: 160, label: '流程状态', placeholder: '流程状态', options: [
{ label: '待测量', value: 'WAIT_MEASURE' }, { label: '测量中', value: 'MEASURING' },
{ label: '待测量审核', value: 'MEASURE_PENDING' }, { label: '测量已驳回', value: 'MEASURE_REJECTED' },
{ label: '待计费', value: 'WAIT_PRICING' }, { label: '费用处理中', value: 'FEE_CONFIRMING' },
{ label: '入库处理中', value: 'INBOUNDING' }, { label: '已入库', value: 'INBOUNDED' }, { label: '已作废', value: 'VOIDED' },
] },
{ key: 'orderStatus', type: 'input', width: 200, placeholder: '订单状态码' },
{ key: 'measureReviewStatus', type: 'select', width: 160, placeholder: '测量审核状态', options: [
{ key: 'orderStatus', type: 'input', width: 200, label: '订单状态码', placeholder: '订单状态码' },
{ key: 'measureReviewStatus', type: 'select', width: 160, label: '测量审核状态', placeholder: '测量审核状态', options: [
{ label: '待审核', value: 'PENDING' }, { label: '审核通过', value: 'APPROVED' }, { label: '审核驳回', value: 'REJECTED' },
] },
{ key: 'feeStatus', type: 'select', width: 160, placeholder: '费用状态', options: [
{ key: 'feeStatus', type: 'select', width: 160, label: '费用状态', placeholder: '费用状态', options: [
{ label: '未计费', value: 'UNPRICED' }, { label: '已试算', value: 'CALCULATED' },
{ label: '已确认', value: 'CONFIRMED' }, { label: '已发送', value: 'SENT' }, { label: '已作废', value: 'VOIDED' },
] },
{ key: 'feeReviewStatus', type: 'input', width: 200, placeholder: '费用审核状态码' },
{ key: 'customerName', type: 'input', width: 260, placeholder: '客户名称' },
{ key: 'destinationCode', type: 'input', width: 220, placeholder: '目的仓编码' },
{ key: 'holdFlag', type: 'select', width: 160, placeholder: '挂起状态', options: [{ label: '已挂起', value: 'true' }, { label: '未挂起', value: 'false' }] },
{ key: 'expectedArrivalRange', type: 'daterange', width: 320, placeholder: '预计到达日期' },
{ key: 'feeReviewStatus', type: 'input', width: 200, label: '费用审核状态码', placeholder: '费用审核状态码' },
{ key: 'customerName', type: 'input', width: 260, label: '客户名称', placeholder: '客户名称' },
{ key: 'destinationCode', type: 'input', width: 220, label: '目的仓编码', placeholder: '目的仓编码' },
{ key: 'holdFlag', type: 'select', width: 160, label: '挂起状态', placeholder: '挂起状态', options: [{ label: '已挂起', value: 'true' }, { label: '未挂起', value: 'false' }] },
{ key: 'expectedArrivalRange', type: 'daterange', width: 320, label: '预计到达日期', placeholder: '预计到达日期' },
])
const queryFields = computed(() => showAdvancedFilters.value
@@ -380,7 +381,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
<template>
<div class="business-page inbound-orders-page">
<QueryToolbar v-model="filters" class="surface-panel inbound-query-toolbar" ariaLabel="入库单筛选" :fields="queryFields" :search-on-reset="false" @search="applyFilters" @reset="resetFilters">
<QueryToolbar v-model="filters" class="surface-panel inbound-query-toolbar" ariaLabel="入库单筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="inbound-orders-query" :search-on-reset="false" @search="applyFilters" @reset="resetFilters">
<template #actions>
<el-button :aria-expanded="showAdvancedFilters" @click="toggleAdvancedFilters">
<SlidersHorizontal :size="16" />
@@ -423,7 +424,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
</div>
</div>
</header>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="inbound-orders-table" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openWorkspace(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button link @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div></template>
<template #inbound="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
<template #orderStatus="{ row }"><div class="status-stack"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag><span>订单:{{ text((row as InboundOrderSummary).orderStatusSnapshot) }}</span></div></template>
@@ -54,10 +54,11 @@ const filters = ref<PartnerFilters>({
})
const appliedFilters = ref<PartnerFilters>({ ...filters.value })
const queryFields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', placeholder: '单位名称、编码、联系人或电话' },
{ key: 'keyword', type: 'input', label: '往来单位关键词', placeholder: '单位名称、编码、联系人或电话' },
{
key: 'type',
type: 'select',
label: '单位类型',
placeholder: '全部类型',
options: [
{ label: '客户', value: 'CUSTOMER' },
@@ -71,6 +72,7 @@ const queryFields = computed<QueryField[]>(() => [
{
key: 'status',
type: 'select',
label: '状态',
placeholder: '全部状态',
options: [
{ label: '启用', value: 'ACTIVE' },
@@ -147,7 +149,7 @@ const partnerColumns: DataTableColumn[] = [
{ key: 'contact', label: '联系人', minWidth: 165, slotName: 'contact' },
{ key: 'address', label: '地址', minWidth: 240, slotName: 'address' },
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
{ prop: 'updatedAt', label: '更新时间', minWidth: 140, formatter: (row: PlatformPartnerResource) => formatUpdatedAt(row.updatedAt) },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 140, formatter: (row: PlatformPartnerResource) => formatUpdatedAt(row.updatedAt) },
]
watch(
@@ -340,7 +342,7 @@ onMounted(() => {
<template>
<div class="business-page partners-page">
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="往来单位筛选" :fields="queryFields" @search="searchPartners" />
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="往来单位筛选" :fields="queryFields" preference-key="partners-query" @search="searchPartners" />
<section v-if="loadError" class="surface-panel partners-error" role="alert">
@@ -368,6 +370,7 @@ onMounted(() => {
v-model:page-size="pageSize"
:data="rows"
:columns="partnerColumns"
preference-key="partners-table"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100]"
@@ -42,10 +42,11 @@ type ExpressCompanyFilters = QueryModel & {
const filters = ref<ExpressCompanyFilters>({ keyword: '', status: '' })
const appliedFilters = ref<ExpressCompanyFilters>({ ...filters.value })
const queryFields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', placeholder: '快递公司名称或编码', props: { maxlength: 128, 'aria-label': '快递公司关键词' } },
{ key: 'keyword', type: 'input', label: '快递公司关键词', placeholder: '快递公司名称或编码', props: { maxlength: 128, 'aria-label': '快递公司关键词' } },
{
key: 'status',
type: 'select',
label: '状态',
placeholder: '全部状态',
props: { 'aria-label': '快递公司状态' },
options: [
@@ -115,11 +116,11 @@ function formatUpdatedAt(value?: string | null) {
const expressCompanyColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'company', label: '快递公司', minWidth: 210, slotName: 'company' },
{ prop: 'servicePhone', label: '客服电话', minWidth: 130, formatter: (row: PlatformExpressCompanyResource) => row.servicePhone || '—' },
{ key: 'servicePhone', prop: 'servicePhone', label: '客服电话', minWidth: 130, formatter: (row: PlatformExpressCompanyResource) => row.servicePhone || '—' },
{ key: 'website', label: '官方网站', minWidth: 210, slotName: 'website' },
{ key: 'trackingUrlTemplate', label: '轨迹查询模板', minWidth: 260, slotName: 'trackingUrlTemplate' },
{ key: 'remark', label: '备注', minWidth: 200, slotName: 'remark' },
{ prop: 'updatedAt', label: '更新时间', minWidth: 150, formatter: (row: PlatformExpressCompanyResource) => formatUpdatedAt(row.updatedAt) },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150, formatter: (row: PlatformExpressCompanyResource) => formatUpdatedAt(row.updatedAt) },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
]
@@ -279,7 +280,7 @@ onMounted(() => {
<el-button size="small" @click="loadExpressCompanies()">重新查询</el-button>
</el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="快递公司筛选" :fields="queryFields" @search="applyFilters" />
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="快递公司筛选" :fields="queryFields" preference-key="express-companies-query" @search="applyFilters" />
<section class="surface-panel table-panel">
<div class="panel-heading">
@@ -297,6 +298,7 @@ onMounted(() => {
v-model:page-size="pageSize"
:data="rows"
:columns="expressCompanyColumns"
preference-key="express-companies-table"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100]"
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
approvePlatformUnifiedOrder,
addPlatformUnifiedOrderNote,
assignPlatformUnifiedOrderCarrier,
getPlatformUnifiedOrder,
listPlatformUnifiedOrders,
@@ -81,4 +82,10 @@ describe('unified orders API contract', () => {
expect(http.put).toHaveBeenCalledWith('/platform/v1/orders/first-leg/90001/carrier', { carrierId: '30001' }, { params: { tenantId: '208' } })
expect(http.post).toHaveBeenCalledWith('/platform/v1/orders/first-leg/90001/approve', undefined, { params: { tenantId: '208' } })
})
it('writes platform internal notes through the unified order boundary', async () => {
vi.mocked(http.post).mockResolvedValue({ data: '91001' })
await expect(addPlatformUnifiedOrderNote('208', '90001', '已确认船期')).resolves.toBe('91001')
expect(http.post).toHaveBeenCalledWith('/platform/v1/orders/90001/internal-notes', { content: '已确认船期' }, { params: { tenantId: '208' } })
})
})
@@ -89,6 +89,43 @@ export interface UnifiedOrderResource {
transport: UnifiedOrderTransportSummary | null
createdAt: string | null
updatedAt: string | null
/** Optional fields returned by newer first-leg/inbound projections. */
expectedArrivalAt?: string | null
feeReviewStatus?: string | null
holdCargo?: boolean | null
holdReason?: string | null
companyName?: string | null
businessType?: string | null
pickupMethod?: string | null
pickupAt?: string | null
pickupContactPhone?: string | null
pickupAddress?: string | null
pickupPostalCode?: string | null
pickupContactEmail?: string | null
taxMode?: string | null
fullContainer?: boolean | null
customsInspection?: boolean | null
totalPalletCount?: number | null
chargeableVolumeCbm?: number | null
volumetricWeightKg?: number | null
totalNetWeightKg?: number | null
location?: string | null
destinationType?: string | null
destinationAddress?: string | null
destinationPostalCode?: string | null
destinationContact?: string | null
destinationContactPhone?: string | null
measurementCompletedAt?: string | null
inboundCompletedAt?: string | null
associatedFiles?: string[] | null
markFiles?: string[] | null
pcOrderFiles?: string[] | null
expressCompany?: string | null
expressTrackingNo?: string | null
remark?: string | null
customerRemark?: string | null
internalRemark?: string | null
createdBy?: string | null
}
export interface UnifiedOrderTimelineEvent {
@@ -289,6 +326,14 @@ export function approvePlatformUnifiedOrder(tenantId: string, orderId: string) {
).then(unwrapApiResponse)
}
export function addPlatformUnifiedOrderNote(tenantId: string, orderId: string, content: string) {
return http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(
`${unifiedOrdersApiPath}/${orderId}/internal-notes`,
{ content },
{ params: { tenantId } },
).then(unwrapApiResponse)
}
export function unifiedOrderStatusLabel(status: UnifiedOrderMainStatus) {
return unifiedOrderStatusOptions.find((item) => item.value === status)?.label || status
}
@@ -47,6 +47,9 @@ describe('UnifiedOrdersView contract', () => {
it('covers real list states and keeps operation column first and left-fixed', () => {
expect(source).toContain("{ key: 'actions', label: '操作', width: 180, fixed: 'left'")
expect(source).toContain("{ key: 'selection', type: 'selection', width: 52, fixed: 'left'")
expect(source).toContain('批量审核接口待接入')
expect(source).toContain('@selection-change="handleSelectionChange"')
expect(source).toContain('title="订单列表暂时无法加载"')
expect(source).toContain('empty-text="暂无符合条件的订单"')
expect(source).toContain(':loading="loading"')
@@ -84,6 +87,16 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('订单详情暂时无法加载')
})
it('renders order detail as a routed full page instead of a drawer', () => {
expect(source).toContain('<template v-if="orderId">')
expect(source).toContain('class="order-detail-header"')
expect(source).toContain('aria-label="订单详情模块"')
expect(source).toContain("{ key: 'basic', label: '基本信息'")
expect(source).toContain("{ key: 'measurement', label: '测量数据'")
expect(source).not.toContain('<AppDrawer')
expect(source).not.toContain('detailOpen')
})
it('exposes the C06 workflow entry only for inbound-backed downstream states', () => {
expect(source).toContain("name: 'unified-order-workflow'")
expect(source).toContain("['WAIT_SPLIT', 'LOADING', 'LOADED'].includes(row.mainStatus)")
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ChevronDown, ChevronUp, CircleCheck, ClipboardList, Eye, RefreshCw, SlidersHorizontal } from '@lucide/vue'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, ClipboardList, Eye, History, ListChecks, PackageOpen, RefreshCw, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
approvePlatformUnifiedOrder,
assignPlatformUnifiedOrderCarrier,
addPlatformUnifiedOrderNote,
listPlatformUnifiedOrders,
unifiedOrderStatusOptions,
type UnifiedOrderDetailResource,
@@ -20,7 +21,6 @@ import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -52,6 +52,7 @@ const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const records = ref<UnifiedOrderResource[]>([])
const selectedOrders = ref<UnifiedOrderResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const page = ref(1)
@@ -66,7 +67,7 @@ const warehouseLoadError = ref<ApiErrorDetails | null>(null)
const detail = ref<UnifiedOrderDetailResource | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const activeDetailTab = ref('basic')
const approvalOpen = ref(false)
const approvalLoadingId = ref('')
const approvalSaving = ref(false)
@@ -76,6 +77,9 @@ const carriers = ref<PlatformPartnerResource[]>([])
const approvalOrder = ref<UnifiedOrderResource | null>(null)
const carrierFormRef = ref<FormInstance>()
const carrierForm = reactive({ carrierId: '' })
const noteOpen = ref(false)
const noteSaving = ref(false)
const noteContent = ref('')
const showAdvancedFilters = ref(false)
const filters = ref<FilterModel>(emptyFilters())
const carrierRules: FormRules = {
@@ -84,6 +88,15 @@ const carrierRules: FormRules = {
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const detailTabs = [
{ key: 'basic', label: '基本信息', icon: Box },
{ key: 'cargo', label: '货品信息', icon: PackageOpen },
{ key: 'fees', label: '费用信息', icon: WalletCards },
{ key: 'history', label: '状态历史', icon: History },
{ key: 'measurement', label: '测量数据', icon: Ruler },
{ key: 'other', label: '其他', icon: StickyNote },
]
const statusTabs = computed(() => [
{ value: '' as const, label: '全部' },
...unifiedOrderStatusOptions,
@@ -141,21 +154,66 @@ const advancedQueryFields = computed<QueryField[]>(() => [
{ key: 'fbaCode', type: 'input', width: 220, label: '商品 FBA 编码', placeholder: '请输入商品 FBA 编码', props: { 'aria-label': '商品 FBA 编码' } },
])
const allQueryFields = computed(() => [...primaryQueryFields.value, ...advancedQueryFields.value])
const queryFields = computed(() => showAdvancedFilters.value
? [...primaryQueryFields.value, ...advancedQueryFields.value]
? allQueryFields.value
: primaryQueryFields.value)
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单信息', minWidth: 220, slotName: 'order' },
{ key: 'mainStatus', label: '主状态', width: 132, slotName: 'mainStatus' },
{ key: 'customer', label: '客户', minWidth: 180, slotName: 'customer' },
{ key: 'receivingWarehouseName', label: '平台收货仓', minWidth: 160, slotName: 'receivingWarehouseName' },
{ key: 'destinationWarehouseName', label: '目的仓', minWidth: 160, slotName: 'destinationWarehouseName' },
{ key: 'transport', label: '运输/装柜', minWidth: 180, slotName: 'transport' },
{ key: 'cargo', label: '货物数据', minWidth: 180, slotName: 'cargo' },
{ key: 'expectedShipDate', label: '预计出运', minWidth: 150, slotName: 'expectedShipDate' },
{ key: 'updatedAt', label: '最近更新', minWidth: 150, slotName: 'updatedAt' },
{ key: 'selection', type: 'selection', width: 52, fixed: 'left', align: 'center', selectable: canBulkReview },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, slotName: 'field' },
{ key: 'internalOrderStatus', prop: 'internalOrderStatus', label: '订单状态', minWidth: 130, slotName: 'field' },
{ key: 'measurementReviewStatus', prop: 'measurementReviewStatus', label: '测量数据审核状态', minWidth: 170, slotName: 'field' },
{ key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到达时间', minWidth: 150, slotName: 'field' },
{ key: 'feeReviewStatus', prop: 'feeReviewStatus', label: '费用审核状态', minWidth: 140, slotName: 'field' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'field' },
{ key: 'receivingWarehouseName', prop: 'receivingWarehouseName', label: '仓库', minWidth: 150, slotName: 'field' },
{ key: 'destinationWarehouseName', prop: 'destinationWarehouseName', label: '交货仓', minWidth: 150, slotName: 'field' },
{ key: 'loadingStatus', prop: 'loadingStatus', label: '配载状态', minWidth: 130, slotName: 'field' },
{ key: 'holdCargo', prop: 'holdCargo', label: '是否扣货', minWidth: 110, slotName: 'field' },
{ key: 'holdReason', prop: 'holdReason', label: '扣货理由', minWidth: 160, slotName: 'field' },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'field' },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜号', minWidth: 130, slotName: 'field' },
{ key: 'companyName', prop: 'companyName', label: '公司', minWidth: 150, slotName: 'field' },
{ key: 'businessType', prop: 'businessType', label: '业务类型', minWidth: 130, slotName: 'field' },
{ key: 'pickupMethod', prop: 'pickupMethod', label: '提货方式', minWidth: 130, slotName: 'field' },
{ key: 'pickupAt', prop: 'pickupAt', label: '提货时间', minWidth: 150, slotName: 'field' },
{ key: 'pickupContactPhone', prop: 'pickupContactPhone', label: '提货联系人电话', minWidth: 150, slotName: 'field' },
{ key: 'pickupAddress', prop: 'pickupAddress', label: '提货地址', minWidth: 200, slotName: 'field' },
{ key: 'pickupPostalCode', prop: 'pickupPostalCode', label: '提货邮编', minWidth: 110, slotName: 'field' },
{ key: 'pickupContactEmail', prop: 'pickupContactEmail', label: '提货联系人邮箱', minWidth: 190, slotName: 'field' },
{ key: 'channelName', prop: 'channelName', label: '渠道', minWidth: 130, slotName: 'field' },
{ key: 'taxMode', prop: 'taxMode', label: '买单/退税', minWidth: 130, slotName: 'field' },
{ key: 'fullContainer', prop: 'fullContainer', label: '是否整柜', minWidth: 110, slotName: 'field' },
{ key: 'customsInspection', prop: 'customsInspection', label: '商检验', minWidth: 110, slotName: 'field' },
{ key: 'sailingAt', prop: 'sailingAt', label: '开船时间', minWidth: 150, slotName: 'field' },
{ key: 'portArrivalAt', prop: 'portArrivalAt', label: '到港时间', minWidth: 150, slotName: 'field' },
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', minWidth: 100, slotName: 'field' },
{ key: 'totalGrossWeightKg', prop: 'totalGrossWeightKg', label: '总毛重(T.K.GW)', minWidth: 140, slotName: 'field' },
{ key: 'chargeableWeightKg', prop: 'chargeableWeightKg', label: '计费毛重', minWidth: 120, slotName: 'field' },
{ key: 'totalVolumeCbm', prop: 'totalVolumeCbm', label: '总体积(m³)', minWidth: 120, slotName: 'field' },
{ key: 'chargeableVolumeCbm', prop: 'chargeableVolumeCbm', label: '计费体积(m³)', minWidth: 130, slotName: 'field' },
{ key: 'totalPalletCount', prop: 'totalPalletCount', label: '总托盘数', minWidth: 110, slotName: 'field' },
{ key: 'location', prop: 'location', label: '库位位置', minWidth: 130, slotName: 'field' },
{ key: 'volumetricWeightKg', prop: 'volumetricWeightKg', label: '总材积重', minWidth: 120, slotName: 'field' },
{ key: 'totalNetWeightKg', prop: 'totalNetWeightKg', label: '总净重(T.K.NW)', minWidth: 140, slotName: 'field' },
{ key: 'destinationType', prop: 'destinationType', label: '目的仓类型', minWidth: 130, slotName: 'field' },
{ key: 'destinationAddress', prop: 'destinationAddress', label: '地址', minWidth: 200, slotName: 'field' },
{ key: 'destinationPostalCode', prop: 'destinationPostalCode', label: '邮编', minWidth: 100, slotName: 'field' },
{ key: 'destinationContact', prop: 'destinationContact', label: '联系人', minWidth: 120, slotName: 'field' },
{ key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '联系人电话', minWidth: 140, slotName: 'field' },
{ key: 'measurementCompletedAt', prop: 'measurementCompletedAt', label: '测量完成时间', minWidth: 150, slotName: 'field' },
{ key: 'inboundCompletedAt', prop: 'inboundCompletedAt', label: '入库完成时间', minWidth: 150, slotName: 'field' },
{ key: 'associatedFiles', prop: 'associatedFiles', label: '关联文件', minWidth: 120, slotName: 'field' },
{ key: 'markFiles', prop: 'markFiles', label: '唛头文件', minWidth: 120, slotName: 'field' },
{ key: 'pcOrderFiles', prop: 'pcOrderFiles', label: 'PC端下单文件', minWidth: 140, slotName: 'field' },
{ key: 'expressCompany', prop: 'expressCompany', label: '快递公司', minWidth: 130, slotName: 'field' },
{ key: 'expressTrackingNo', prop: 'expressTrackingNo', label: '快递单号', minWidth: 150, slotName: 'field' },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, slotName: 'field' },
{ key: 'internalRemark', prop: 'internalRemark', label: '客服内部备注', minWidth: 180, slotName: 'field' },
{ key: 'createdBy', prop: 'createdBy', label: '创建人', minWidth: 120, slotName: 'field' },
{ key: 'createdAt', prop: 'createdAt', label: '创建日期', minWidth: 150, slotName: 'field' },
]
function emptyFilters(): FilterModel {
@@ -211,10 +269,42 @@ function formatNumber(value: number | null | undefined, digits = 2) {
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
function displayField(row: UnifiedOrderResource, property: string | undefined) {
if (!property) return '—'
const source = row as unknown as Record<string, unknown>
const nested: Record<string, string> = {
measurementReviewStatus: 'measurement.reviewStatus', expectedArrivalAt: 'expectedArrivalAt',
loadingStatus: 'transport.loadingStatus', containerNo: 'transport.containerNo', cabinetNo: 'transport.cabinetNo',
sailingAt: 'transport.sailingAt', portArrivalAt: 'transport.portArrivalAt',
chargeableWeightKg: 'measurement.chargeableWeightKg', measurementCompletedAt: 'measurement.completedAt',
totalGrossWeightKg: 'measurement.actualGrossWeightKg', totalVolumeCbm: 'measurement.actualVolumeCbm',
expressTrackingNo: 'delivery.trackingNo',
}
const path = nested[property] || property
let value = path.split('.').reduce<unknown>((current, key) => (
current && typeof current === 'object' ? (current as Record<string, unknown>)[key] : undefined
), source)
if ((value === null || value === undefined || value === '') && property === 'internalOrderStatus') value = source.mainStatus
if (value === null || value === undefined || value === '') return '—'
if (typeof value === 'boolean') return value ? '是' : '否'
if (Array.isArray(value)) return value.length ? `${value.length} 个文件` : '—'
if (typeof value === 'number') return formatNumber(value)
if (property.endsWith('At') || property.endsWith('Date')) return formatDate(String(value))
if (property === 'measurementReviewStatus') return measurementStatusLabel(String(value))
if (property === 'internalOrderStatus') {
return ({ SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || String(value)
}
return String(value)
}
function statusLabel(value: UnifiedOrderMainStatus | string | null) {
return unifiedOrderStatusOptions.find((item) => item.value === value)?.label || value || '状态更新'
}
function measurementStatusLabel(value: string | null | undefined) {
return ({ UNRECORDED: '未录入', PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' } as Record<string, string>)[value || ''] || value || '未录入'
}
const nextActionLabels: Record<string, string> = {
APPROVE_ORDER: '审核通过',
CONFIRM_MEASUREMENT: '完成测量',
@@ -234,6 +324,19 @@ function canApprove(row: UnifiedOrderResource) {
return row.mainStatus === 'PENDING_REVIEW' && row.nextAction === 'APPROVE_ORDER' && can('platform:order:approve')
}
function canBulkReview(row: UnifiedOrderResource) {
return canApprove(row)
}
function handleSelectionChange(rows: UnifiedOrderResource[]) {
selectedOrders.value = rows
}
function openBulkReviewPlaceholder() {
if (selectedOrders.value.length === 0) return
ElMessage.info('批量审核接口待接入,请先逐笔审核')
}
async function loadCarriers() {
carrierLoading.value = true
carrierLoadError.value = null
@@ -304,6 +407,31 @@ async function assignCarrierAndApprove() {
}
}
function openNoteDialog() {
if (!detail.value || !can('platform:order:note')) return
noteContent.value = ''
noteOpen.value = true
}
async function saveNote() {
const content = noteContent.value.trim()
if (!detail.value || !content) {
ElMessage.warning('请填写内部备注')
return
}
noteSaving.value = true
try {
await addPlatformUnifiedOrderNote(String(detail.value.summary.tenantId), String(detail.value.summary.orderId), content)
noteOpen.value = false
await loadDetail()
ElMessage.success('内部备注已保存')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '内部备注保存失败,请稍后重试').message)
} finally {
noteSaving.value = false
}
}
async function loadCustomers() {
customerLoading.value = true
customerLoadError.value = null
@@ -369,6 +497,7 @@ async function loadOrders() {
size: pageSize.value,
})
records.value = result.records
selectedOrders.value = []
total.value = result.total
} catch (error) {
records.value = []
@@ -393,8 +522,10 @@ function reset() {
void loadOrders()
}
function openDetail(row: UnifiedOrderResource) {
void router.push({ name: 'unified-order-detail', params: { orderId: String(row.orderId) }, query: { tenantId: String(row.tenantId) } })
async function openDetail(row: UnifiedOrderResource) {
await router.push({ name: 'unified-order-detail', params: { orderId: String(row.orderId) }, query: { tenantId: String(row.tenantId) } })
await nextTick()
window.scrollTo({ top: 0 })
}
function openMeasurement(row: UnifiedOrderResource) {
@@ -416,7 +547,6 @@ function openWorkflow(row: UnifiedOrderResource) {
}
function closeDetail() {
detailOpen.value = false
detail.value = null
detailError.value = null
void router.push({ name: 'unified-orders' })
@@ -427,12 +557,10 @@ async function loadDetail() {
const tenantId = typeof route.query.tenantId === 'string' ? route.query.tenantId : ''
if (!tenantId) {
detailError.value = { message: '订单详情缺少客户标识,请从订单列表重新打开' }
detailOpen.value = true
return
}
detailLoading.value = true
detailError.value = null
detailOpen.value = true
try {
detail.value = await getPlatformUnifiedOrder(tenantId, orderId.value)
} catch (error) {
@@ -445,8 +573,10 @@ async function loadDetail() {
watch([page, pageSize], () => { void loadOrders() })
watch(orderId, (value) => {
if (value) void loadDetail()
else detailOpen.value = false
if (value) {
activeDetailTab.value = 'basic'
void loadDetail()
}
}, { immediate: true })
onMounted(() => {
void loadCustomers()
@@ -457,35 +587,132 @@ onMounted(() => {
<template>
<section class="unified-orders-page">
<div class="status-tabs" role="tablist" aria-label="订单主状态">
<button
v-for="(tab, index) in statusTabs"
:key="tab.value || 'ALL'"
type="button"
role="tab"
:aria-selected="filters.mainStatus === tab.value"
aria-controls="unified-orders-results"
:tabindex="filters.mainStatus === tab.value ? 0 : -1"
:class="['status-tab', { 'is-active': filters.mainStatus === tab.value }]"
@click="selectStatus(tab.value)"
@keydown="handleTabKeydown($event, index)"
>{{ tab.label }}</button>
</div>
<template v-if="orderId">
<header class="order-detail-header">
<div class="order-detail-heading">
<el-button class="back-button" @click="closeDetail"><ArrowLeft :size="17" />返回订单列表</el-button>
<span class="order-mark" aria-hidden="true"><Box :size="23" /></span>
<div class="order-title">
<span>入仓码</span>
<h1>{{ detail?.summary.inboundCode || '暂未生成' }}</h1>
<p>{{ detail?.summary.orderNo || `订单 ${orderId}` }}</p>
</div>
<StatusBadge v-if="detail" :status="statusLabel(detail.summary.mainStatus)" />
</div>
<div v-if="detail" class="detail-header-actions">
<el-button v-if="canApprove(detail.summary)" type="success" plain :loading="approvalLoadingId === String(detail.summary.orderId)" @click="openApproval(detail.summary)"><CircleCheck :size="15" />审核通过</el-button>
<el-button v-if="workflowEligible(detail.summary) && can('platform:firstleg:inbound:view')" type="primary" @click="openWorkflow(detail.summary)"><ClipboardList :size="15" />后半程作业</el-button>
</div>
</header>
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" ariaLabel="订单列表筛选" :fields="queryFields" :search-on-reset="false" @search="search" @reset="reset">
<template #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
</el-button>
</template>
</QueryToolbar>
<LoadErrorState v-if="detailError" class="detail-load-error" title="订单详情暂时无法加载" :message="detailError.message" :loading="detailLoading" @retry="loadDetail">
<template #details><ApiErrorMeta :details="detailError" /></template>
</LoadErrorState>
<div v-else v-loading="detailLoading" class="order-detail-content">
<template v-if="detail">
<section class="detail-overview" aria-label="订单概览">
<article><span>客户</span><strong>{{ customerName(detail.summary) }}</strong><small>{{ detail.summary.channelName || '未配置渠道' }}</small></article>
<article><span>总箱数</span><strong>{{ detail.summary.totalPackageCount ?? '—' }}<small>箱</small></strong><small>{{ detail.order.cargoes.length }} 条货品明细</small></article>
<article><span>实测毛重</span><strong>{{ formatNumber(detail.summary.measurement?.actualGrossWeightKg) }}<small>kg</small></strong><small>{{ measurementStatusLabel(detail.summary.measurement?.reviewStatus) }}</small></article>
<article><span>实测体积</span><strong>{{ formatNumber(detail.summary.measurement?.actualVolumeCbm, 3) }}<small>m³</small></strong><small>最近更新 {{ formatDate(detail.summary.updatedAt) }}</small></article>
</section>
<div class="detail-tabs" role="tablist" aria-label="订单详情模块">
<button v-for="tab in detailTabs" :id="`order-detail-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeDetailTab === tab.key }" :aria-selected="activeDetailTab === tab.key" :aria-controls="`order-detail-panel-${tab.key}`" :tabindex="activeDetailTab === tab.key ? 0 : -1" @click="activeDetailTab = tab.key"><component :is="tab.icon" :size="17" /><span>{{ tab.label }}</span></button>
</div>
<section :id="`order-detail-panel-${activeDetailTab}`" class="detail-panel" role="tabpanel" :aria-labelledby="`order-detail-tab-${activeDetailTab}`" tabindex="0">
<template v-if="activeDetailTab === 'basic'">
<div class="panel-title"><div><h2>订单基本信息</h2><p>{{ detail.summary.orderNo || `订单 ${detail.summary.orderId}` }}</p></div><Box :size="19" /></div>
<dl class="order-info-grid">
<div><dt>公司</dt><dd>{{ customerName(detail.summary) }}</dd></div>
<div><dt>运输方式</dt><dd>{{ detail.order.order?.transportMode || '—' }}</dd></div>
<div><dt>当前状态</dt><dd><StatusBadge :status="statusLabel(detail.summary.mainStatus)" /></dd></div>
<div><dt>平台收货仓</dt><dd>{{ detail.summary.receivingWarehouseName || '—' }}</dd></div>
<div><dt>目的仓</dt><dd>{{ detail.summary.destinationWarehouseName || '—' }}</dd></div>
<div><dt>目的国家</dt><dd>{{ detail.order.order?.destinationCountryCode || '—' }}</dd></div>
<div><dt>渠道</dt><dd>{{ detail.summary.channelName || '—' }}</dd></div>
<div><dt>承运商</dt><dd>{{ detail.order.order?.carrierName || '待平台指派' }}</dd></div>
<div><dt>预计出运</dt><dd>{{ formatDate(detail.summary.expectedShipDate) }}</dd></div>
<div><dt>集装箱号</dt><dd>{{ detail.summary.transport?.containerNo || '—' }}</dd></div>
<div><dt>船名 / 航次</dt><dd>{{ detail.summary.transport?.vesselName || '—' }} / {{ detail.summary.transport?.voyageNo || '—' }}</dd></div>
<div><dt>开船时间</dt><dd>{{ formatDate(detail.summary.transport?.sailingAt) }}</dd></div>
<div><dt>到港时间</dt><dd>{{ formatDate(detail.summary.transport?.portArrivalAt) }}</dd></div>
<div><dt>下一步</dt><dd>{{ canApprove(detail.summary) ? '选择承运商并审核' : nextActionBoundary(detail.summary) || '—' }}</dd></div>
<div><dt>创建时间</dt><dd>{{ formatDate(detail.summary.createdAt) }}</dd></div>
</dl>
</template>
<template v-else-if="activeDetailTab === 'cargo'">
<div class="panel-title"><div><h2>货品信息</h2><p>共 {{ detail.order.cargoes.length }} 条</p></div><PackageOpen :size="19" /></div>
<div class="detail-table-wrap">
<el-table :data="detail.order.cargoes" empty-text="暂无货品信息">
<el-table-column prop="itemNo" label="#" width="64" /><el-table-column prop="cargoName" label="品名" min-width="170" /><el-table-column prop="packageType" label="包装" min-width="110" /><el-table-column prop="packageCount" label="箱数" width="90" /><el-table-column prop="grossWeightKg" label="毛重(kg)" min-width="110" /><el-table-column prop="volumeCbm" label="体积(m³)" min-width="110" />
</el-table>
</div>
</template>
<template v-else-if="activeDetailTab === 'fees'">
<div class="panel-title"><div><h2>费用信息</h2><p>订单费用与审核结果</p></div><WalletCards :size="19" /></div><el-empty description="暂无费用数据" :image-size="64" />
</template>
<template v-else-if="activeDetailTab === 'history'">
<div class="panel-title"><div><h2>状态历史</h2><p>共 {{ detail.timeline.length }} 条</p></div><History :size="19" /></div>
<el-empty v-if="detail.timeline.length === 0" description="暂无状态历史" :image-size="64" />
<ol v-else class="history-list" aria-label="状态历史"><li v-for="(item, index) in detail.timeline" :key="`${item.source || 'event'}-${item.occurredAt || index}`"><span class="history-marker" aria-hidden="true" /><div><div class="history-line"><strong>{{ item.action || item.eventType || '状态更新' }}</strong><StatusBadge :status="statusLabel(item.mainStatus)" /></div><p>{{ item.operatorName || '系统' }} · {{ formatDate(item.occurredAt) }}</p><p v-if="item.remark" class="history-remark">{{ item.remark }}</p></div></li></ol>
</template>
<template v-else-if="activeDetailTab === 'measurement'">
<div class="panel-title"><div><h2>测量数据</h2><p>{{ detail.summary.measurement?.completedAt ? `完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}</p></div><Ruler :size="19" /></div>
<dl class="measurement-grid"><div><dt>审核状态</dt><dd>{{ measurementStatusLabel(detail.summary.measurement?.reviewStatus) }}</dd></div><div><dt>实测毛重</dt><dd>{{ formatNumber(detail.summary.measurement?.actualGrossWeightKg) }} kg</dd></div><div><dt>实测体积</dt><dd>{{ formatNumber(detail.summary.measurement?.actualVolumeCbm, 3) }} m³</dd></div><div><dt>计费重量</dt><dd>{{ formatNumber(detail.summary.measurement?.chargeableWeightKg) }} kg</dd></div></dl>
</template>
<template v-else>
<div class="panel-title"><div><h2>内部备注</h2><p>仅平台内部可见</p></div><el-button v-if="can('platform:order:note')" type="primary" size="small" @click="openNoteDialog"><StickyNote :size="15" />添加备注</el-button></div>
<el-empty v-if="detail.order.internalNotes.length === 0" description="暂无内部备注" :image-size="64" />
<ul v-else class="notes-list" aria-label="内部备注"><li v-for="item in detail.order.internalNotes" :key="String(item.id)"><p>{{ item.content }}</p><span>操作人 {{ item.createdBy || '系统' }} · {{ formatDate(item.createdAt) }}</span></li></ul>
</template>
</section>
</template>
</div>
</template>
<template v-else>
<div class="orders-query-panel">
<div class="status-tabs" role="tablist" aria-label="订单主状态">
<button
v-for="(tab, index) in statusTabs"
:key="tab.value || 'ALL'"
type="button"
role="tab"
:aria-selected="filters.mainStatus === tab.value"
aria-controls="unified-orders-results"
:tabindex="filters.mainStatus === tab.value ? 0 : -1"
:class="['status-tab', { 'is-active': filters.mainStatus === tab.value }]"
@click="selectStatus(tab.value)"
@keydown="handleTabKeydown($event, index)"
>{{ tab.label }}</button>
</div>
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" ariaLabel="订单列表筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="unified-orders-query" :search-on-reset="false" @search="search" @reset="reset">
<template #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
</el-button>
</template>
</QueryToolbar>
</div>
<LoadErrorState v-if="loadError" class="orders-load-error" title="订单列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadOrders">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<div v-else id="unified-orders-results" class="table-card" role="tabpanel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleSelectionChange">
<template #toolbar-start>
<el-button type="primary" plain :disabled="selectedOrders.length === 0" title="批量审核接口待接入" @click="openBulkReviewPlaceholder"><ListChecks :size="16" />批量审核<span v-if="selectedOrders.length">({{ selectedOrders.length }})</span></el-button>
</template>
<template #actions="{ row }">
<div class="row-actions">
<el-button link type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>
@@ -495,6 +722,7 @@ onMounted(() => {
<span v-if="nextActionBoundary(row as UnifiedOrderResource)" class="action-boundary">{{ nextActionBoundary(row as UnifiedOrderResource) }}</span>
</div>
</template>
<template #field="{ row, column }">{{ displayField(row as UnifiedOrderResource, column.property) }}</template>
<template #order="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).orderNo || `订单 ${(row as UnifiedOrderResource).orderId}` }}</strong><span v-if="(row as UnifiedOrderResource).inboundCode">入仓码 {{ (row as UnifiedOrderResource).inboundCode }}</span></div></template>
<template #mainStatus="{ row }"><StatusBadge :status="statusLabel((row as UnifiedOrderResource).mainStatus)" /></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong><span>客户 ID {{ (row as UnifiedOrderResource).tenantId }}</span></div></template>
@@ -507,17 +735,11 @@ onMounted(() => {
</DataTable>
</div>
<AppDrawer v-model="detailOpen" :title="detail ? `订单详情 · ${detail.summary.orderNo || detail.summary.orderId}` : '订单详情'" size="min(760px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
<LoadErrorState v-if="detailError" title="订单详情暂时无法加载" :message="detailError.message" :loading="detailLoading" @retry="loadDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<div v-else-if="detail" class="detail-content">
<section class="detail-summary" aria-label="订单概览">
<div class="detail-summary__header"><div><span class="eyebrow">订单主状态</span><h2>{{ detail.summary.orderNo || `订单 ${detail.summary.orderId}` }}</h2></div><div class="detail-summary__actions"><StatusBadge :status="statusLabel(detail.summary.mainStatus)" /><el-button v-if="canApprove(detail.summary)" type="success" plain :loading="approvalLoadingId === String(detail.summary.orderId)" @click="openApproval(detail.summary)"><CircleCheck :size="15" />审核通过</el-button><el-button v-if="workflowEligible(detail.summary) && can('platform:firstleg:inbound:view')" type="primary" plain @click="openWorkflow(detail.summary)"><ClipboardList :size="15" />后半程作业</el-button></div></div>
<dl class="detail-grid"><div><dt>客户</dt><dd>{{ customerName(detail.summary) }}</dd></div><div><dt>入仓码</dt><dd>{{ detail.summary.inboundCode || '—' }}</dd></div><div><dt>平台收货仓</dt><dd>{{ detail.summary.receivingWarehouseName || '—' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.summary.destinationWarehouseName || '—' }}</dd></div><div><dt>承运商</dt><dd>{{ detail.order.order?.carrierName || '待平台指派' }}</dd></div><div><dt>下一步</dt><dd>{{ canApprove(detail.summary) ? '选择承运商并审核' : nextActionBoundary(detail.summary) || '—' }}</dd></div><div><dt>最近更新</dt><dd>{{ formatDate(detail.summary.updatedAt) }}</dd></div></dl>
</section>
<section class="detail-section" aria-label="状态历史"><h3>状态历史</h3><el-empty v-if="detail.timeline.length === 0" description="暂无状态历史" :image-size="56" /><ol v-else class="history-list"><li v-for="(item, index) in detail.timeline" :key="`${item.source || 'event'}-${item.occurredAt || index}`"><StatusBadge :status="statusLabel(item.mainStatus)" /><div><strong>{{ item.action || item.eventType || '状态更新' }}</strong><p>{{ item.operatorName || '系统' }} · {{ formatDate(item.occurredAt) }}</p><p v-if="item.remark">{{ item.remark }}</p></div></li></ol></section>
<section class="detail-section" aria-label="内部备注"><h3>内部备注</h3><el-empty v-if="detail.order.internalNotes.length === 0" description="暂无内部备注" :image-size="56" /><ul v-else class="notes-list"><li v-for="item in detail.order.internalNotes" :key="String(item.id)"><p>{{ item.content }}</p><span>操作人 {{ item.createdBy || '系统' }} · {{ formatDate(item.createdAt) }}</span></li></ul></section>
</div>
</AppDrawer>
</template>
<AppDialog v-model="noteOpen" title="添加内部备注" confirm-text="保存备注" :confirm-loading="noteSaving" @confirm="saveNote">
<el-input v-model="noteContent" type="textarea" :rows="5" maxlength="2000" show-word-limit placeholder="请输入仅平台内部可见的备注" aria-label="内部备注内容" />
</AppDialog>
<AppDialog v-model="approvalOpen" title="选择承运商并审核" confirm-text="指派并审核通过" confirm-type="success" :loading="carrierLoading" :confirm-loading="approvalSaving" :confirm-disabled="Boolean(carrierLoadError) || carriers.length === 0" destroy-on-close @confirm="assignCarrierAndApprove" @cancel="closeApproval">
<el-alert title="订单审核通过后将进入履约流程;承运商名称由后端公共主数据生成快照。" type="info" :closable="false" show-icon />
@@ -543,6 +765,7 @@ onMounted(() => {
.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: 13px; line-height: 1.6; }
.page-intro :deep(.el-button) { flex: 0 0 auto; }
.orders-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid var(--cc-border); scrollbar-width: thin; }
.status-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }
.status-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }
@@ -550,7 +773,7 @@ onMounted(() => {
.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); }
.status-tabs + :deep(.query-toolbar) { border-top: 0; }
.orders-query-panel :deep(.query-toolbar) { border: 0; }
.orders-load-error { margin-top: 12px; }
.table-card { margin-top: 16px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
@@ -561,24 +784,57 @@ onMounted(() => {
.primary-cell { display: grid; min-width: 0; gap: 4px; }
.primary-cell strong { overflow-wrap: anywhere; }
.primary-cell span { color: var(--cc-muted); font-size: 12px; overflow-wrap: anywhere; }
.detail-content { display: grid; gap: 22px; min-width: 0; }
.detail-summary, .detail-section { padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.detail-summary__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.detail-summary__actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.eyebrow { color: var(--cc-muted); font-size: 12px; }
.detail-summary h2 { margin: 4px 0 0; color: var(--cc-text); font-size: 18px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 20px; margin: 20px 0 0; }
.detail-grid div { min-width: 0; }
.detail-grid dt { color: var(--cc-muted); font-size: 12px; }
.detail-grid dd { margin: 4px 0 0; color: var(--cc-text); overflow-wrap: anywhere; }
.detail-section h3 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
.history-list, .notes-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.history-list li { display: flex; align-items: flex-start; gap: 10px; padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
.history-list li:last-child, .notes-list li:last-child { padding-bottom: 0; border-bottom: 0; }
.history-list strong { font-size: 13px; }
.history-list p, .notes-list p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.notes-list li { padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
.order-detail-header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 18px; }
.order-detail-heading { display: flex; min-width: 0; align-items: center; gap: 12px; }
.back-button { flex: 0 0 auto; }
.order-mark { display: grid; width: 44px; height: 44px; flex: 0 0 44px; place-items: center; border-radius: 8px; background: #1d2939; color: #fff; }
.order-title { min-width: 0; }
.order-title > span { display: block; margin-bottom: 2px; color: var(--cc-muted); font-size: 11px; }
.order-title h1 { margin: 0; overflow: hidden; color: #172033; font-size: 22px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
.order-title p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
.detail-header-actions { display: flex; flex: 0 0 auto; gap: 8px; }
.detail-header-actions :deep(.el-button + .el-button) { margin-left: 0; }
.detail-load-error { min-height: 280px; }
.order-detail-content { min-height: 520px; }
.detail-overview { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; margin-bottom: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.detail-overview article { min-width: 0; padding: 18px 20px; border-right: 1px solid var(--cc-border); }
.detail-overview article:last-child { border-right: 0; }
.detail-overview article > span, .detail-overview article > small { display: block; overflow: hidden; color: var(--cc-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.detail-overview strong { display: block; margin: 7px 0 5px; overflow: hidden; color: #172033; font-size: 20px; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
.detail-overview strong small { margin-left: 5px; color: #475467; font-size: 11px; }
.detail-tabs { display: grid; grid-template-columns: repeat(6, minmax(124px, 1fr)); overflow-x: auto; border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 8px 8px 0 0; background: #fff; }
.detail-tabs button { position: relative; display: flex; min-height: 56px; align-items: center; justify-content: center; gap: 7px; padding: 0 14px; border: 0; border-right: 1px solid #edf0f4; background: transparent; color: #667085; font-weight: 650; cursor: pointer; }
.detail-tabs button:last-child { border-right: 0; }
.detail-tabs button::after { position: absolute; right: 14px; bottom: 0; left: 14px; height: 3px; background: transparent; content: ''; }
.detail-tabs button:hover { background: #fafbff; color: #344054; }
.detail-tabs button.active { background: #f7f8ff; color: var(--cc-primary); }
.detail-tabs button.active::after { background: var(--cc-primary); }
.detail-panel { min-width: 0; min-height: 390px; padding: 22px; border: 1px solid var(--cc-border); border-radius: 0 0 8px 8px; background: #fff; }
.panel-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; padding-bottom: 14px; border-bottom: 1px solid var(--cc-border); }
.panel-title h2 { margin: 0; color: #344054; font-size: 16px; }
.panel-title p { margin: 5px 0 0; color: var(--cc-muted); font-size: 11px; }
.panel-title > svg { color: var(--cc-primary); }
.order-info-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; margin: 0; overflow: hidden; border: 1px solid #edf0f4; background: #edf0f4; }
.order-info-grid > div, .measurement-grid > div { min-width: 0; padding: 14px 16px; background: #fff; }
.order-info-grid dt, .measurement-grid dt { margin-bottom: 6px; color: var(--cc-muted); font-size: 11px; }
.order-info-grid dd, .measurement-grid dd { margin: 0; color: #25324a; font-size: 13px; font-weight: 650; line-height: 1.5; overflow-wrap: anywhere; }
.detail-table-wrap { overflow-x: auto; }
.detail-table-wrap :deep(.el-table) { min-width: 720px; --el-table-header-bg-color: #f8fafc; }
.measurement-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0; border: 1px solid var(--cc-border); background: var(--cc-border); }
.history-list, .notes-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.history-list li { position: relative; display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 12px; padding-bottom: 20px; }
.history-list li:not(:last-child)::after { position: absolute; top: 15px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); content: ''; }
.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 3px; border: 3px solid var(--cc-primary); border-radius: 50%; background: #fff; }
.history-line { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.history-list strong { color: #344054; font-size: 13px; }
.history-list p, .notes-list p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; }
.history-list .history-remark { margin-top: 8px; padding: 10px 12px; background: #f8fafc; color: #475467; }
.notes-list li { padding: 14px 0; border-bottom: 1px solid #edf0f4; }
.notes-list li:first-child { padding-top: 0; }
.notes-list li:last-child { padding-bottom: 0; border-bottom: 0; }
.notes-list li > p { margin: 0; color: var(--cc-text); font-size: 13px; }
.notes-list span { display: block; margin-top: 5px; color: var(--cc-muted); font-size: 12px; }
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .detail-grid { grid-template-columns: 1fr; } .detail-summary__header { flex-direction: column; } .detail-summary__actions { justify-content: flex-start; } .row-actions { flex-wrap: wrap; } }
@media (max-width: 960px) { .detail-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } .detail-overview article:nth-child(2) { border-right: 0; } .detail-overview article:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); } .order-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .measurement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { display: grid; grid-template-columns: auto 40px minmax(0, 1fr) auto; } .order-mark { width: 40px; height: 40px; } .detail-header-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } .detail-header-actions :deep(.el-button) { min-height: 44px; } .detail-overview, .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .detail-overview article { border-right: 0; border-bottom: 1px solid var(--cc-border); } .detail-overview article:last-child { border-bottom: 0; } .detail-tabs { grid-template-columns: repeat(6, 132px); } .detail-panel { padding: 16px; } }
@media (max-width: 420px) { .order-detail-heading { grid-template-columns: 1fr auto; } .back-button { grid-column: 1 / -1; justify-self: start; } .order-mark { display: none; } .detail-header-actions { grid-template-columns: 1fr; } }
</style>
@@ -69,6 +69,7 @@ const queryFields = computed<QueryField[]>(() => [
{
key: 'warehouseId',
type: 'select',
label: '所属仓库',
width: 300,
placeholder: warehouseLoading.value ? '仓库加载中' : '全部仓库',
props: {
@@ -81,10 +82,10 @@ const queryFields = computed<QueryField[]>(() => [
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
})),
},
{ key: 'zoneCode', type: 'input', width: 210, placeholder: '库区编码', props: { maxlength: 50, 'aria-label': '库区编码' } },
{ key: 'locationCode', type: 'input', width: 220, placeholder: '库位编码', props: { maxlength: 50, 'aria-label': '库位编码' } },
{ key: 'zoneCode', type: 'input', width: 210, label: '库区编码', placeholder: '库区编码', props: { maxlength: 50, 'aria-label': '库区编码' } },
{ key: 'locationCode', type: 'input', width: 220, label: '库位编码', placeholder: '库位编码', props: { maxlength: 50, 'aria-label': '库位编码' } },
{
key: 'locationType', type: 'select', width: 170, placeholder: '全部类型', props: { 'aria-label': '库位类型' },
key: 'locationType', type: 'select', width: 170, label: '库位类型', placeholder: '全部类型', props: { 'aria-label': '库位类型' },
options: [
{ label: '货架位', value: 'SHELF' },
{ label: '托盘位', value: 'PALLET' },
@@ -93,7 +94,7 @@ const queryFields = computed<QueryField[]>(() => [
],
},
{
key: 'status', type: 'select', width: 160, placeholder: '全部状态', props: { 'aria-label': '库位状态' },
key: 'status', type: 'select', width: 160, label: '库位状态', placeholder: '全部状态', props: { 'aria-label': '库位状态' },
options: [
{ label: '可用', value: 'AVAILABLE' },
{ label: '已预占', value: 'RESERVED' },
@@ -492,6 +493,7 @@ onMounted(() => {
v-model="filters"
ariaLabel="库位筛选"
:fields="queryFields"
preference-key="locations-query"
@search="applyFilters"
/>
@@ -525,6 +527,7 @@ onMounted(() => {
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
preference-key="locations-table"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100, 200]"
@@ -48,6 +48,7 @@ const queryFields = computed<QueryField[]>(() => [
{
key: 'warehouseId',
type: 'select',
label: '所属仓库',
width: 300,
placeholder: warehouseLoading.value ? '正在加载所属仓库' : warehouseError.value ? '所属仓库加载失败' : '全部仓库',
options: domesticWarehouses.value.map((warehouse) => ({
@@ -60,10 +61,11 @@ const queryFields = computed<QueryField[]>(() => [
'aria-label': '所属仓库',
},
},
{ key: 'palletCode', type: 'input', width: 240, placeholder: '托盘编码(模糊查询)', props: { maxlength: 50, 'aria-label': '托盘编码' } },
{ key: 'palletCode', type: 'input', width: 240, label: '托盘编码', placeholder: '托盘编码(模糊查询)', props: { maxlength: 50, 'aria-label': '托盘编码' } },
{
key: 'palletType',
type: 'select',
label: '托盘类型',
width: 180,
placeholder: '全部托盘类型',
options: palletTypeOptions.map((value) => ({ label: value, value })),
@@ -120,10 +122,10 @@ const { captureBaseline } = useUnsavedWorkRegistration({
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', minWidth: 224, fixed: 'left', slotName: 'actions' },
{ key: 'pallet', label: '托盘编码', minWidth: 190, slotName: 'pallet' },
{ prop: 'palletType', label: '托盘类型', minWidth: 140 },
{ key: 'palletType', prop: 'palletType', label: '托盘类型', minWidth: 140 },
{ key: 'capacity', label: '托盘容积(m³)', minWidth: 160, slotName: 'capacity' },
{ key: 'weight', label: '托盘重量(kg)', minWidth: 160, slotName: 'weight' },
{ prop: 'color', label: '颜色', minWidth: 120 },
{ key: 'color', prop: 'color', label: '颜色', minWidth: 120 },
{ key: 'remark', label: '备注', minWidth: 220, slotName: 'remark' },
]
@@ -355,6 +357,7 @@ onMounted(() => {
class="surface-panel pallet-filter-toolbar"
ariaLabel="托盘筛选"
:fields="queryFields"
preference-key="pallets-query"
:search-on-reset="false"
@search="applyFilters"
@reset="resetFilters"
@@ -386,6 +389,7 @@ onMounted(() => {
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
preference-key="pallets-table"
:loading="loading || warehouseLoading"
:total="total"
row-key="id"
@@ -536,7 +540,7 @@ onMounted(() => {
.pallet-cell > span, .detail-hero > span { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--cc-primary); }
.pallet-cell > span { width: 34px; height: 34px; }
.remark-text { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-height: 1.55; }
.detail-body { min-height: 180px; }
.detail-body { min-height: 180px; padding: 24px; box-sizing: border-box; overflow-x: hidden; }
.detail-hero { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.detail-hero > span { width: 46px; height: 46px; }
.detail-hero h2, .detail-hero p { margin: 0; }
@@ -574,6 +578,7 @@ onMounted(() => {
.pallet-editor-footer > div :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) {
.detail-body { padding: 16px; }
.panel-heading { align-items: stretch; flex-direction: column; }
.panel-heading > .el-button { width: 100%; min-height: 44px; }
.pallet-row-actions :deep(.el-button) { min-height: 36px; padding-inline: 5px; }
@@ -134,16 +134,16 @@ type WarehouseFilters = QueryModel & { keyword: string; warehouseType: Warehouse
const filters = ref<WarehouseFilters>({ keyword: '', warehouseType: '', status: '' })
const appliedFilters = ref<WarehouseFilters>({ ...filters.value })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '仓库名称、编码、城市或地址' },
{ key: 'keyword', type: 'input', label: '仓库关键词', placeholder: '仓库名称、编码、城市或地址' },
{
key: 'warehouseType', type: 'select', placeholder: '全部仓库类型', options: [
key: 'warehouseType', type: 'select', label: '仓库类型', placeholder: '全部仓库类型', options: [
{ label: warehouseTypeLabels.DOMESTIC, value: 'DOMESTIC' },
{ label: warehouseTypeLabels.FBA, value: 'FBA' },
{ label: warehouseTypeLabels.OVERSEAS, value: 'OVERSEAS' },
],
},
{
key: 'status', type: 'select', placeholder: '全部状态', options: [
key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [
{ label: '启用', value: 'ACTIVE' },
{ label: '停用', value: 'INACTIVE' },
],
@@ -165,7 +165,7 @@ const warehouseColumns: DataTableColumn[] = [
{ key: 'warehouse', label: '仓库', minWidth: 190, slotName: 'warehouse' },
{ key: 'warehouseType', label: '仓库类型', width: 120, slotName: 'warehouseType' },
{ key: 'countryCode', label: '国家/地区', width: 140, slotName: 'countryCode' },
{ prop: 'timezone', label: '时区', width: 110 },
{ key: 'timezone', prop: 'timezone', label: '时区', width: 110 },
{ key: 'city', label: '所在城市', minWidth: 140, slotName: 'city' },
{ key: 'address', label: '地址', minWidth: 280, slotName: 'address' },
{ key: 'contact', label: '联系人', minWidth: 150, slotName: 'contact' },
@@ -672,7 +672,7 @@ onMounted(() => void loadWarehouses())
<el-button size="small" @click="loadWarehouses()">重新查询</el-button>
</el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" @search="queryWarehouses" />
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" preference-key="warehouses-query" @search="queryWarehouses" />
<section class="surface-panel table-panel" aria-live="polite">
<div class="panel-heading warehouse-heading">
@@ -699,6 +699,7 @@ onMounted(() => void loadWarehouses())
v-model:page-size="pageSize"
:data="rows"
:columns="warehouseColumns"
preference-key="warehouses-table"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100]"
+60 -2
View File
@@ -1,5 +1,40 @@
import { describe, expect, it } from 'vitest'
import { createPaginationItems, normalizePage, normalizePageSizes } from './DataTable.vue'
import { computed, defineComponent } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import DataTable, { createPaginationItems, normalizePage, normalizePageSizes, type DataTableColumn } from './DataTable.vue'
import LayoutPreferenceList from './LayoutPreferenceList.vue'
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
const TableStub = defineComponent({ template: '<div><slot /></div>', methods: { doLayout() {} } })
const ColumnStub = defineComponent({
inheritAttrs: false,
props: { label: String },
template: '<div class="column-stub" :data-label="label"><slot /></div>',
})
const ButtonStub = defineComponent({ template: '<button type="button"><slot /></button>' })
const PopoverStub = defineComponent({ template: '<div><slot name="reference" /><slot /></div>' })
const configurableColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', fixed: 'left' },
{ key: 'name', prop: 'name', label: '名称' },
{ key: 'status', prop: 'status', label: '状态' },
]
function mountTable() {
return shallowMount(DataTable, {
props: { data: [], columns: configurableColumns, preferenceKey: 'orders-table' },
global: {
provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') },
stubs: {
ElTable: TableStub,
ElTableColumn: ColumnStub,
ElButton: ButtonStub,
ElPopover: PopoverStub,
},
directives: { loading: {} },
},
})
}
describe('DataTable pagination helpers', () => {
it('normalizes page-size options', () => {
@@ -30,3 +65,26 @@ describe('DataTable pagination helpers', () => {
expect(createPaginationItems(30, 30)).toEqual([1, 'ellipsis-start', 26, 27, 28, 29, 30])
})
})
describe('DataTable column preferences', () => {
beforeEach(() => localStorage.clear())
it('restores column order and visibility while locking the actions column', async () => {
localStorage.setItem(preferenceStorageKey('table', 'user-1', 'orders-table'), JSON.stringify({
version: 1,
order: ['actions', 'status', 'name'],
hidden: ['name', 'actions'],
}))
const wrapper = mountTable()
expect(wrapper.findAll('.column-stub').map((column) => column.attributes('data-label'))).toEqual(['操作', '状态'])
const settings = wrapper.findComponent(LayoutPreferenceList)
const items = settings.props('items') as Array<{ key: string; movable: boolean; hideable: boolean }>
expect(items.find((item) => item.key === 'actions')).toMatchObject({ movable: false, hideable: false })
settings.vm.$emit('visibility', 'name', true)
settings.vm.$emit('move', 'name', 0)
await wrapper.vm.$nextTick()
expect(wrapper.findAll('.column-stub').map((column) => column.attributes('data-label'))).toEqual(['操作', '名称', '状态'])
})
})
+144 -6
View File
@@ -7,6 +7,9 @@ export interface DataTableColumn {
columnKey?: string
slotName?: string
headerSlotName?: string
hideable?: boolean
movable?: boolean
defaultVisible?: boolean
[attribute: string]: unknown
}
@@ -39,8 +42,16 @@ export function createPaginationItems(page: number, pageCount: number): Paginati
</script>
<script setup lang="ts">
import { computed, useSlots, watch } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
import { computed, inject, nextTick, ref, useSlots, watch } from 'vue'
import { ChevronLeft, ChevronRight, Settings2 } from '@lucide/vue'
import LayoutPreferenceList, { type LayoutPreferenceItem } from './LayoutPreferenceList.vue'
import {
UI_PREFERENCE_SCOPE,
readLayoutPreference,
removeLayoutPreference,
writeLayoutPreference,
type LayoutPreferenceSnapshot,
} from '@/shared/utils/uiLayoutPreference'
defineOptions({ inheritAttrs: false })
@@ -51,6 +62,7 @@ const props = withDefaults(defineProps<{
emptyText?: string
total?: number
pageSizes?: number[]
preferenceKey?: string
}>(), {
loading: false,
emptyText: '暂无数据',
@@ -61,6 +73,114 @@ const props = withDefaults(defineProps<{
const page = defineModel<number>('page', { default: 1 })
const pageSize = defineModel<number>('pageSize', { default: 10 })
const slots = useSlots()
const preferenceScope = inject(UI_PREFERENCE_SCOPE, computed(() => 'anonymous'))
const preference = ref<LayoutPreferenceSnapshot>({ version: 1, order: [], hidden: [] })
const tableRef = ref<{ doLayout: () => void }>()
const preferenceEnabled = computed(() => Boolean(props.preferenceKey))
function columnId(column: DataTableColumn) {
const value = column.key ?? column.columnKey ?? column.prop ?? column.property
return value === undefined || value === null || value === '' ? undefined : String(value)
}
function systemColumn(column: DataTableColumn) {
const id = columnId(column)
return id === 'actions'
|| column.fixed === 'left'
|| ['selection', 'expand', 'index'].includes(String(column.type || ''))
}
function columnMovable(column: DataTableColumn) {
return column.movable ?? !systemColumn(column)
}
function columnHideable(column: DataTableColumn) {
return column.hideable ?? !systemColumn(column)
}
const configurableColumns = computed(() => props.columns.filter((column) => columnId(column)))
const columnKeys = computed(() => configurableColumns.value.map((column) => columnId(column) as string))
function applyPreference() {
const defaultHidden = configurableColumns.value.flatMap((column) => column.defaultVisible === false ? [columnId(column) as string] : [])
preference.value = readLayoutPreference(
'table',
preferenceScope.value,
props.preferenceKey || '',
columnKeys.value,
defaultHidden,
)
}
watch(
[preferenceScope, () => props.preferenceKey, () => columnKeys.value.join('\u0000')],
applyPreference,
{ immediate: true },
)
const orderedColumns = computed(() => {
if (!preferenceEnabled.value) return props.columns
const columnsByKey = new Map(configurableColumns.value.map((column) => [columnId(column) as string, column]))
return preference.value.order.flatMap((key) => {
const column = columnsByKey.get(key)
return column ? [column] : []
})
})
const visibleColumns = computed(() => orderedColumns.value.filter((column) => {
const id = columnId(column)
return !id || !columnHideable(column) || !preference.value.hidden.includes(id)
}))
const visibleHideableCount = computed(() => visibleColumns.value.filter(columnHideable).length)
const preferenceItems = computed<LayoutPreferenceItem[]>(() => orderedColumns.value.flatMap((column) => {
const key = columnId(column)
if (!key) return []
const visible = !preference.value.hidden.includes(key)
const hideable = columnHideable(column)
return [{
key,
label: column.label || (String(column.type || '') === 'selection' ? '选择' : key),
visible,
hideable,
movable: columnMovable(column),
hideDisabled: visible && hideable && visibleHideableCount.value <= 1,
}]
}))
function persistPreference(order: string[], hidden = preference.value.hidden) {
preference.value = { version: 1, order, hidden }
writeLayoutPreference('table', preferenceScope.value, props.preferenceKey || '', preference.value)
}
function moveColumn(key: string, targetIndex: number) {
const movableKeys = orderedColumns.value.filter(columnMovable).map((column) => columnId(column) as string)
const currentIndex = movableKeys.indexOf(key)
if (currentIndex < 0) return
movableKeys.splice(currentIndex, 1)
movableKeys.splice(Math.min(Math.max(targetIndex, 0), movableKeys.length), 0, key)
const movableSet = new Set(movableKeys)
let nextMovableIndex = 0
const order = preference.value.order.map((id) => movableSet.has(id) ? movableKeys[nextMovableIndex++] : id)
persistPreference(order)
}
function setColumnVisibility(key: string, visible: boolean) {
const column = configurableColumns.value.find((candidate) => columnId(candidate) === key)
if (!column || !columnHideable(column)) return
if (!visible && visibleHideableCount.value <= 1) return
const hidden = visible
? preference.value.hidden.filter((id) => id !== key)
: [...new Set([...preference.value.hidden, key])]
persistPreference(preference.value.order, hidden)
}
function resetPreference() {
removeLayoutPreference('table', preferenceScope.value, props.preferenceKey || '')
applyPreference()
}
watch(visibleColumns, () => {
void nextTick(() => tableRef.value?.doLayout())
})
const normalizedTotal = computed(() => {
const value = props.total ?? 0
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0
@@ -88,7 +208,7 @@ watch([page, pageCount], ([value, count]) => {
if (value !== nextValue) page.value = nextValue
}, { immediate: true })
function columnAttrs({ key, slotName, headerSlotName, ...attrs }: DataTableColumn) {
function columnAttrs({ key, slotName, headerSlotName, hideable, movable, defaultVisible, ...attrs }: DataTableColumn) {
return attrs
}
@@ -114,9 +234,24 @@ function setPage(value: number) {
<template>
<div class="data-table">
<el-table v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :aria-busy="loading">
<div v-if="$slots['toolbar-start'] || preferenceEnabled" class="data-table__toolbar">
<div class="data-table__toolbar-start"><slot name="toolbar-start" /></div>
<el-popover v-if="preferenceEnabled" placement="bottom-end" :width="340" trigger="click" popper-class="cc-layout-preference-popper">
<template #reference>
<el-button aria-label="列设置" title="列设置"><Settings2 :size="16" /></el-button>
</template>
<LayoutPreferenceList
:items="preferenceItems"
allow-visibility
@move="moveColumn"
@visibility="setColumnVisibility"
@reset="resetPreference"
/>
</el-popover>
</div>
<el-table ref="tableRef" v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :aria-busy="loading">
<el-table-column
v-for="(column, index) in columns"
v-for="(column, index) in visibleColumns"
:key="columnRenderKey(column, index)"
v-bind="columnAttrs(column)"
>
@@ -168,6 +303,9 @@ function setPage(value: number) {
<style scoped>
.data-table { min-width: 0; }
.data-table__toolbar { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 16px; border-bottom: 1px solid var(--cc-border); }
.data-table__toolbar-start { display: flex; min-width: 0; flex: 1; align-items: center; flex-wrap: wrap; gap: 8px; }
.data-table__toolbar :deep(.el-button + .el-button) { margin-left: 0; }
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
.data-table__pagination-left { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; }
.data-table__pagination-controls, .data-table__page-size, .data-table__page-buttons { display: flex; align-items: center; gap: 8px; }
@@ -176,5 +314,5 @@ function setPage(value: number) {
.data-table__page-buttons { gap: 4px; }
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; }
.data-table__page-ellipsis { display: inline-flex; width: 24px; height: 32px; align-items: center; justify-content: center; }
@media (max-width: 720px) { .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
@media (max-width: 720px) { .data-table__toolbar { align-items: flex-start; } .data-table__toolbar > :deep(.el-button) { width: 44px; min-width: 44px; height: 44px; padding: 0; } .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Sortable from 'sortablejs'
import { ChevronDown, ChevronUp, GripVertical, RotateCcw } from '@lucide/vue'
export interface LayoutPreferenceItem {
key: string
label: string
visible: boolean
hideable: boolean
movable: boolean
hideDisabled?: boolean
}
const props = withDefaults(defineProps<{
items: LayoutPreferenceItem[]
allowVisibility?: boolean
}>(), {
allowVisibility: false,
})
const emit = defineEmits<{
move: [key: string, targetIndex: number]
visibility: [key: string, visible: boolean]
reset: []
}>()
const sortableList = ref<HTMLElement>()
const movableItems = computed(() => props.items.filter((item) => item.movable))
const lockedItems = computed(() => props.items.filter((item) => !item.movable))
let sortable: Sortable | undefined
function syncSortableOrder() {
void nextTick(() => sortable?.sort(movableItems.value.map((item) => item.key), false))
}
function createSortable() {
if (!sortableList.value) return
sortable?.destroy()
sortable = Sortable.create(sortableList.value, {
animation: 150,
handle: '.layout-preference-list__handle',
ghostClass: 'is-dragging',
dataIdAttr: 'data-key',
onEnd(event) {
if (event.oldIndex === undefined || event.newIndex === undefined || event.oldIndex === event.newIndex) return
const item = movableItems.value[event.oldIndex]
if (item) emit('move', item.key, event.newIndex)
syncSortableOrder()
},
})
}
function moveBy(key: string, offset: number) {
const index = movableItems.value.findIndex((item) => item.key === key)
if (index < 0) return
emit('move', key, index + offset)
}
onMounted(createSortable)
onBeforeUnmount(() => sortable?.destroy())
watch(() => props.items.map((item) => item.key), syncSortableOrder)
</script>
<template>
<div class="layout-preference-list">
<p class="layout-preference-list__hint">拖动调整顺序,也可使用上下移动按钮。</p>
<ul v-if="lockedItems.length" class="layout-preference-list__items is-locked" aria-label="固定项目">
<li v-for="item in lockedItems" :key="item.key" class="layout-preference-list__item is-locked">
<span class="layout-preference-list__handle is-disabled" aria-hidden="true"><GripVertical :size="16" /></span>
<el-checkbox v-if="allowVisibility" :model-value="item.visible" disabled>{{ item.label }}</el-checkbox>
<span v-else class="layout-preference-list__label">{{ item.label }}</span>
<span class="layout-preference-list__fixed">固定</span>
</li>
</ul>
<ul ref="sortableList" class="layout-preference-list__items" aria-label="可排序项目">
<li v-for="(item, index) in movableItems" :key="item.key" :data-key="item.key" class="layout-preference-list__item">
<button class="layout-preference-list__handle" type="button" :aria-label="`拖动${item.label}`" :title="`拖动${item.label}`">
<GripVertical :size="16" />
</button>
<el-checkbox
v-if="allowVisibility"
:model-value="item.visible"
:disabled="!item.hideable || item.hideDisabled"
@update:model-value="emit('visibility', item.key, Boolean($event))"
>{{ item.label }}</el-checkbox>
<span v-else class="layout-preference-list__label">{{ item.label }}</span>
<div class="layout-preference-list__moves">
<el-button text :disabled="index === 0" :aria-label="`上移${item.label}`" :title="`上移${item.label}`" @click="moveBy(item.key, -1)"><ChevronUp :size="16" /></el-button>
<el-button text :disabled="index === movableItems.length - 1" :aria-label="`下移${item.label}`" :title="`下移${item.label}`" @click="moveBy(item.key, 1)"><ChevronDown :size="16" /></el-button>
</div>
</li>
</ul>
<el-button class="layout-preference-list__reset" text @click="emit('reset')"><RotateCcw :size="15" />恢复默认</el-button>
</div>
</template>
<style scoped>
.layout-preference-list { display: grid; gap: 10px; }
.layout-preference-list__hint { margin: 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.layout-preference-list__items { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
.layout-preference-list__items.is-locked { padding-bottom: 8px; border-bottom: 1px solid var(--cc-border); }
.layout-preference-list__item { display: grid; min-height: 38px; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 6px; padding: 3px 4px; border: 1px solid transparent; background: #fff; }
.layout-preference-list__item:hover { border-color: var(--cc-border); background: #f8fafc; }
.layout-preference-list__item.is-dragging { border-color: var(--cc-primary); background: #eef5ff; }
.layout-preference-list__handle { display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; padding: 0; border: 0; background: transparent; color: var(--cc-muted); cursor: grab; }
.layout-preference-list__handle:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 1px; }
.layout-preference-list__handle.is-disabled { cursor: default; opacity: .45; }
.layout-preference-list__label { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.layout-preference-list__fixed { color: var(--cc-muted); font-size: 11px; }
.layout-preference-list__moves { display: flex; gap: 0; }
.layout-preference-list__moves :deep(.el-button) { width: 30px; height: 30px; margin-left: 0; padding: 0; }
.layout-preference-list__reset { justify-self: start; margin-left: 0; }
@media (max-width: 720px) { .layout-preference-list__item { min-height: 44px; } .layout-preference-list__moves :deep(.el-button) { width: 40px; height: 40px; } }
</style>
@@ -0,0 +1,63 @@
import { computed, defineComponent } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import QueryToolbar, { type QueryField } from './QueryToolbar.vue'
import LayoutPreferenceList from './LayoutPreferenceList.vue'
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
const FieldStub = defineComponent({
inheritAttrs: false,
props: { placeholder: String },
template: '<input :data-placeholder="placeholder" />',
})
const ButtonStub = defineComponent({ template: '<button type="button"><slot /></button>' })
const PopoverStub = defineComponent({ template: '<div><slot name="reference" /><slot /></div>' })
const fields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '关键词' },
{ key: 'status', type: 'select', label: '状态', placeholder: '状态' },
{ key: 'createdRange', type: 'daterange', label: '创建日期', placeholder: '创建日期' },
]
function mountToolbar() {
return shallowMount(QueryToolbar, {
props: {
modelValue: { keyword: '', status: '', createdRange: [] },
'onUpdate:modelValue': () => undefined,
ariaLabel: '订单筛选',
fields,
preferenceKey: 'orders-query',
},
global: {
provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') },
stubs: {
ElInput: FieldStub,
ElSelect: FieldStub,
ElDatePicker: FieldStub,
ElButton: ButtonStub,
ElPopover: PopoverStub,
},
},
})
}
describe('QueryToolbar layout preferences', () => {
beforeEach(() => localStorage.clear())
it('restores and updates the user field order', async () => {
localStorage.setItem(preferenceStorageKey('query', 'user-1', 'orders-query'), JSON.stringify({
version: 1,
order: ['status', 'keyword', 'createdRange'],
hidden: [],
}))
const wrapper = mountToolbar()
expect(wrapper.findAll('input').map((input) => input.attributes('data-placeholder')))
.toEqual(['状态', '关键词', '创建日期'])
wrapper.findComponent(LayoutPreferenceList).vm.$emit('move', 'createdRange', 0)
await wrapper.vm.$nextTick()
expect(wrapper.findAll('input').map((input) => input.attributes('data-placeholder')))
.toEqual(['创建日期', '状态', '关键词'])
})
})
+84 -3
View File
@@ -1,5 +1,14 @@
<script setup lang="ts">
import { Search, RotateCcw } from '@lucide/vue'
import { computed, inject, ref, watch } from 'vue'
import { Search, RotateCcw, Settings2 } from '@lucide/vue'
import LayoutPreferenceList, { type LayoutPreferenceItem } from './LayoutPreferenceList.vue'
import {
UI_PREFERENCE_SCOPE,
readLayoutPreference,
removeLayoutPreference,
writeLayoutPreference,
type LayoutPreferenceSnapshot,
} from '@/shared/utils/uiLayoutPreference'
defineOptions({ inheritAttrs: false })
@@ -16,7 +25,7 @@ export interface QueryOption {
export interface QueryField {
key: string
type: QueryFieldType
label?: string
label: string
width?: string | number
placeholder?: string
options?: QueryOption[]
@@ -26,6 +35,8 @@ export interface QueryField {
const props = withDefaults(defineProps<{
ariaLabel: string
fields: QueryField[]
availableFields?: QueryField[]
preferenceKey?: string
searchText?: string
resetText?: string
searchOnReset?: boolean
@@ -40,6 +51,69 @@ const emit = defineEmits<{
search: [model: QueryModel]
reset: [model: QueryModel]
}>()
const preferenceScope = inject(UI_PREFERENCE_SCOPE, computed(() => 'anonymous'))
const preference = ref<LayoutPreferenceSnapshot>({ version: 1, order: [], hidden: [] })
const preferenceFields = computed(() => props.availableFields ?? props.fields)
const preferenceFieldKeys = computed(() => preferenceFields.value.map((field) => field.key))
const preferenceEnabled = computed(() => Boolean(props.preferenceKey))
function fieldLabel(field: QueryField) {
const ariaLabel = field.props?.['aria-label']
return field.label || (typeof ariaLabel === 'string' ? ariaLabel : '') || field.placeholder || field.key
}
function applyPreference() {
preference.value = readLayoutPreference(
'query',
preferenceScope.value,
props.preferenceKey || '',
preferenceFieldKeys.value,
)
}
watch(
[preferenceScope, () => props.preferenceKey, () => preferenceFieldKeys.value.join('\u0000')],
applyPreference,
{ immediate: true },
)
const orderedPreferenceFields = computed(() => {
const fieldsByKey = new Map(preferenceFields.value.map((field) => [field.key, field]))
return preference.value.order.flatMap((key) => {
const field = fieldsByKey.get(key)
return field ? [field] : []
})
})
const orderedFields = computed(() => {
const visibleKeys = new Set(props.fields.map((field) => field.key))
return orderedPreferenceFields.value.filter((field) => visibleKeys.has(field.key))
})
const preferenceItems = computed<LayoutPreferenceItem[]>(() => orderedPreferenceFields.value.map((field) => ({
key: field.key,
label: fieldLabel(field),
visible: true,
hideable: false,
movable: true,
})))
function persistPreference(order: string[]) {
preference.value = { version: 1, order, hidden: [] }
writeLayoutPreference('query', preferenceScope.value, props.preferenceKey || '', preference.value)
}
function moveField(key: string, targetIndex: number) {
const order = [...preference.value.order]
const currentIndex = order.indexOf(key)
if (currentIndex < 0) return
order.splice(currentIndex, 1)
order.splice(Math.min(Math.max(targetIndex, 0), order.length), 0, key)
persistPreference(order)
}
function resetPreference() {
removeLayoutPreference('query', preferenceScope.value, props.preferenceKey || '')
applyPreference()
}
function updateField(key: string, value: QueryValue) {
model.value = { ...model.value, [key]: value }
@@ -87,7 +161,7 @@ defineExpose({ search, reset })
:aria-label="ariaLabel"
>
<div class="query-toolbar__filters">
<div v-for="field in fields" :key="field.key" class="query-toolbar__field" :style="fieldStyle(field)">
<div v-for="field in orderedFields" :key="field.key" class="query-toolbar__field" :style="fieldStyle(field)">
<el-input
v-if="field.type === 'input'"
:model-value="model[field.key] as string"
@@ -127,6 +201,12 @@ defineExpose({ search, reset })
<el-button type="primary" @click="search"><Search :size="16" />{{ searchText }}</el-button>
<el-button @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
<slot name="actions" />
<el-popover v-if="preferenceEnabled" placement="bottom-end" :width="340" trigger="click" popper-class="cc-layout-preference-popper">
<template #reference>
<el-button aria-label="调整筛选布局" title="调整筛选布局"><Settings2 :size="16" /></el-button>
</template>
<LayoutPreferenceList :items="preferenceItems" @move="moveField" @reset="resetPreference" />
</el-popover>
</div>
</section>
</template>
@@ -138,5 +218,6 @@ defineExpose({ search, reset })
.query-toolbar__field :deep(.el-input), .query-toolbar__field :deep(.el-select), .query-toolbar__field :deep(.el-date-editor) { width: 100%; max-width: 100%; min-width: 0; }
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
:global(.cc-layout-preference-popper) { max-width: calc(100vw - 24px); }
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__field { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
</style>
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
preferenceStorageKey,
readLayoutPreference,
reconcileOrder,
removeLayoutPreference,
writeLayoutPreference,
} from './uiLayoutPreference'
describe('UI layout preferences', () => {
beforeEach(() => localStorage.clear())
it('reconciles stored order with the current stable keys', () => {
expect(reconcileOrder(['actions', 'name', 'status', 'createdAt'], ['status', 'removed', 'name', 'status']))
.toEqual(['status', 'name', 'actions', 'createdAt'])
})
it('falls back safely when cached JSON is invalid', () => {
localStorage.setItem(preferenceStorageKey('table', 'user-1', 'customers'), '{broken')
expect(readLayoutPreference('table', 'user-1', 'customers', ['actions', 'name'], ['name']))
.toEqual({ version: 1, order: ['actions', 'name'], hidden: ['name'] })
})
it('keeps preferences isolated by user and removes them on reset', () => {
writeLayoutPreference('query', 'user-1', 'orders', { version: 1, order: ['status', 'keyword'], hidden: [] })
expect(readLayoutPreference('query', 'user-1', 'orders', ['keyword', 'status']).order).toEqual(['status', 'keyword'])
expect(readLayoutPreference('query', 'user-2', 'orders', ['keyword', 'status']).order).toEqual(['keyword', 'status'])
removeLayoutPreference('query', 'user-1', 'orders')
expect(readLayoutPreference('query', 'user-1', 'orders', ['keyword', 'status']).order).toEqual(['keyword', 'status'])
})
})
+82
View File
@@ -0,0 +1,82 @@
import type { ComputedRef, InjectionKey } from 'vue'
export type LayoutPreferenceKind = 'query' | 'table'
export interface LayoutPreferenceSnapshot {
version: 1
order: string[]
hidden: string[]
}
export const UI_PREFERENCE_SCOPE: InjectionKey<ComputedRef<string>> = Symbol('ui-preference-scope')
function uniqueKeys(values: readonly unknown[]) {
const seen = new Set<string>()
return values.flatMap((value) => {
if (typeof value !== 'string' || !value || seen.has(value)) return []
seen.add(value)
return [value]
})
}
export function preferenceStorageKey(kind: LayoutPreferenceKind, scope: string, preferenceKey: string) {
return `cc-admin-${kind}-layout:v1:${scope}:${preferenceKey}`
}
export function reconcileOrder(availableKeys: readonly string[], storedOrder: readonly string[]) {
const available = uniqueKeys(availableKeys)
const availableSet = new Set(available)
const retained = uniqueKeys(storedOrder).filter((key) => availableSet.has(key))
const retainedSet = new Set(retained)
return [...retained, ...available.filter((key) => !retainedSet.has(key))]
}
export function readLayoutPreference(
kind: LayoutPreferenceKind,
scope: string,
preferenceKey: string,
availableKeys: readonly string[],
defaultHidden: readonly string[] = [],
): LayoutPreferenceSnapshot {
const fallback: LayoutPreferenceSnapshot = {
version: 1,
order: reconcileOrder(availableKeys, []),
hidden: uniqueKeys(defaultHidden).filter((key) => availableKeys.includes(key)),
}
if (!scope || !preferenceKey) return fallback
try {
const parsed = JSON.parse(localStorage.getItem(preferenceStorageKey(kind, scope, preferenceKey)) || 'null') as Partial<LayoutPreferenceSnapshot> | null
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.order) || !Array.isArray(parsed.hidden)) return fallback
return {
version: 1,
order: reconcileOrder(availableKeys, parsed.order),
hidden: uniqueKeys(parsed.hidden).filter((key) => availableKeys.includes(key)),
}
} catch {
return fallback
}
}
export function writeLayoutPreference(
kind: LayoutPreferenceKind,
scope: string,
preferenceKey: string,
snapshot: LayoutPreferenceSnapshot,
) {
if (!scope || !preferenceKey) return
try {
localStorage.setItem(preferenceStorageKey(kind, scope, preferenceKey), JSON.stringify(snapshot))
} catch {
// Layout preferences are optional and must never block business workflows.
}
}
export function removeLayoutPreference(kind: LayoutPreferenceKind, scope: string, preferenceKey: string) {
if (!scope || !preferenceKey) return
try {
localStorage.removeItem(preferenceStorageKey(kind, scope, preferenceKey))
} catch {
// Ignore unavailable browser storage and keep the in-memory default layout.
}
}