Release/test 合并上生产 #2
@@ -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",
|
||||
|
||||
Generated
+16
@@ -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
@@ -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]"
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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(['操作', '名称', '状态'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(['创建日期', '状态', '关键词'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user