Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
25 changed files with 224 additions and 229 deletions
Showing only changes of commit e5f02c2530 - Show all commits
@@ -227,7 +227,7 @@ onMounted(loadRules)
<template>
<div class="billing-rules-panel">
<div class="panel-title billing-heading">
<div v-if="!canView || loadError" class="panel-title billing-heading">
<div><h2>计费规则</h2><p>{{ customerName }}{{ customerCode ? `(${customerCode})` : '' }}的专属 FBA 计费版本</p></div>
<div class="heading-actions">
<el-button v-if="canView" :loading="loading" aria-label="刷新计费规则" @click="loadRules"><RefreshCw :size="16" />刷新</el-button>
@@ -255,6 +255,8 @@ onMounted(loadRules)
row-key="id"
empty-text="当前客户暂无计费规则"
>
<template #toolbar-start><div><h2>计费规则</h2><p>{{ customerName }}{{ customerCode ? `(${customerCode})` : '' }}的专属 FBA 计费版本</p></div></template>
<template #toolbar-actions><div class="heading-actions"><el-button v-if="canView" :loading="loading" aria-label="刷新计费规则" @click="loadRules"><RefreshCw :size="16" />刷新</el-button><el-button v-if="canEdit" type="primary" :disabled="loading" @click="openEditor()"><Plus :size="16" />新建规则</el-button></div></template>
<template #actions="{ row }">
<div class="row-actions">
<el-button link type="primary" @click="openDetail(asRule(row))"><Eye :size="15" />详情</el-button>
@@ -669,9 +669,10 @@ onMounted(async () => {
<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" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户">
<template #toolbar-start><div class="customers-table-heading"><h2>客户列表</h2><span>共 {{ result.total }} 个客户</span></div></template>
<template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers"><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></template>
<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>
@@ -756,7 +757,6 @@ onMounted(async () => {
<style scoped>
.customers-error { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:16px; padding:18px 20px; border-color:#fecdca; background:#fffafa; }
.customers-error strong { color:#7a271a; font-size:16px; } .customers-error p { margin:4px 0 0; color:#b54708; font-size:12px; overflow-wrap:anywhere; }
.customers-table-header { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:16px; padding:16px 18px; border-bottom:1px solid var(--cc-border); }
.customers-table-heading { display:flex; min-width:0; align-items:baseline; gap:10px; } .customers-table-heading h2 { margin:0; color:var(--cc-text); font-size:16px; } .customers-table-heading span { color:var(--cc-muted); font-size:12px; white-space:nowrap; }
.customers-table-actions { display:flex; flex-wrap:wrap; gap:8px; } .customers-table-actions :deep(.el-button + .el-button) { margin-left:0; }
.table-actions { display:flex; flex-wrap:wrap; align-items:center; gap:2px 8px; } .table-actions :deep(.el-button) { margin-left:0; }
@@ -786,6 +786,6 @@ onMounted(async () => {
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:16px; } .customer-editor-footer > span { color:var(--cc-muted); font-size:12px; text-align:left; } .customer-editor-footer > div { display:flex; flex:0 0 auto; gap:8px; } .customer-editor-footer :deep(.el-button + .el-button) { margin-left:0; }
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:12px; margin-bottom:18px; padding:12px 14px; border-radius:8px; background:#f8fafc; } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--cc-text); font-size:13px; } .decision-summary span { color:var(--cc-muted); font-size:11px; }
.approval-result-warning { display:flex; align-items:flex-start; gap:10px; padding:12px 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong,.approval-result-warning span { display:block; } .approval-result-warning strong { font-size:14px; } .approval-result-warning span { margin-top:4px; font-size:12px; line-height:1.5; } .approval-credentials { margin:18px 0 0; border:1px solid var(--cc-border); border-radius:8px; } .approval-credentials > div { display:grid; grid-template-columns:90px minmax(0,1fr); gap:12px; padding:12px 14px; } .approval-credentials > div + div { border-top:1px solid var(--cc-border); } .approval-credentials dt { color:var(--cc-muted); font-size:12px; } .approval-credentials dd { display:flex; min-width:0; align-items:center; gap:10px; margin:0; } .approval-credentials code { min-width:0; flex:1; overflow-wrap:anywhere; color:var(--cc-text); font-size:14px; font-weight:700; }
@media (max-width:720px) { .customers-error,.customers-table-header,.customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; } }
@media (max-width:720px) { .customers-error,.customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; } }
@media (max-width:360px) { .customers-table-actions,.capital-input { grid-template-columns:1fr; } }
</style>
@@ -241,15 +241,13 @@ onBeforeUnmount(() => {
</section>
<section v-else class="surface-panel table-panel" :aria-busy="loading">
<div class="download-table-header">
<div><h2>下载任务</h2><span>共 {{ result.total }} 个任务</span></div>
<el-button :loading="loading" @click="loadTasks()"><RefreshCw :size="16" />刷新</el-button>
</div>
<div v-if="syncWarning" class="download-sync-warning" role="status">
<span>自动刷新暂时失败,正在保留当前任务并继续重试:{{ syncWarning.message }}</span>
<ApiErrorMeta :details="syncWarning" />
</div>
<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 #toolbar-start><div class="download-table-heading"><h2>下载任务</h2><span>共 {{ result.total }} 个任务</span></div></template>
<template #toolbar-actions><el-button :loading="loading" @click="loadTasks()"><RefreshCw :size="16" />刷新</el-button></template>
<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>
@@ -311,13 +309,12 @@ onBeforeUnmount(() => {
<style scoped>
.download-center-page { min-width: 0; }
.download-error, .download-table-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.download-error { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.download-error { padding: 18px; }
.download-error strong { color: var(--cc-danger); font-size: 14px; }
.download-error p { margin: 5px 0; color: var(--cc-text); font-size: 13px; }
.download-table-header { min-height: 64px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); }
.download-table-header h2 { margin: 0; color: var(--cc-text); font-size: 16px; }
.download-table-header span { display: block; margin-top: 3px; color: var(--cc-muted); font-size: 12px; }
.download-table-heading h2 { margin: 0; color: var(--cc-text); font-size: 16px; }
.download-table-heading span { display: block; margin-top: 3px; color: var(--cc-muted); font-size: 12px; }
.download-sync-warning { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 18px; border-bottom: 1px solid #fedf89; background: #fffaeb; color: #93370d; font-size: 12px; }
.table-actions { display: flex; min-height: 32px; align-items: center; gap: 8px; }
.table-actions :deep(.el-button + .el-button) { margin-left: 0; }
@@ -342,8 +339,8 @@ onBeforeUnmount(() => {
.task-failure p { margin: 6px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; line-height: 1.6; }
small { display: block; margin-top: 3px; color: var(--cc-muted); font-size: 11px; }
@media (max-width: 720px) {
.download-error, .download-table-header, .download-sync-warning { align-items: stretch; flex-direction: column; }
.download-table-header :deep(.el-button), .table-actions :deep(.el-button) { min-height: 44px; }
.download-error, .download-sync-warning { align-items: stretch; flex-direction: column; }
.table-actions :deep(.el-button) { min-height: 44px; }
.table-actions :deep(.el-button) { padding-right: 8px; padding-left: 8px; }
}
@media (max-width: 480px) {
@@ -419,19 +419,6 @@ onMounted(() => {
</LoadErrorState>
<section class="surface-panel table-panel" aria-live="polite">
<div class="panel-heading">
<div>
<h2>价格版本</h2>
<p>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中</p>
</div>
<el-button
v-if="access.can('firstleg:fba-pricing:rate:edit')"
type="primary"
:disabled="masterDataLoading"
@click="openCreate()"
><Plus :size="16" />新增价格版本</el-button>
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
@@ -445,6 +432,8 @@ onMounted(() => {
aria-label="船司 FBA 价格版本列表"
empty-text="暂无符合条件的价格版本"
>
<template #toolbar-start><div><h2>价格版本</h2><p>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中</p></div></template>
<template #toolbar-actions><el-button v-if="access.can('firstleg:fba-pricing:rate:edit')" type="primary" :disabled="masterDataLoading" @click="openCreate()"><Plus :size="16" />新增价格版本</el-button></template>
<template #actions="{ row }">
<div class="table-actions">
<el-button link type="primary" @click="openDetail(row as PlatformCarrierRateResource)"><Eye :size="15" />详情</el-button>
@@ -566,7 +555,6 @@ onMounted(() => {
.detail-remark { margin: 16px 0 0; padding: 12px 14px; border-radius: 10px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
@media (max-width: 720px) {
.panel-heading .el-button { width: 100%; min-height: 44px; }
.detail-grid { grid-template-columns: 1fr; gap: 12px; }
}
</style>
@@ -537,6 +537,7 @@ onMounted(() => void loadTenants())
empty-text="当前没有符合条件的头程订单"
row-key="id"
>
<template #toolbar-start><div><h2>头程订单列表</h2><p>共 {{ total }} 条订单</p></div></template>
<template #actions="{ row }">
<div class="table-actions order-row-actions">
<el-button link type="primary" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="openDetail(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button>
+2 -8
View File
@@ -313,16 +313,10 @@ onMounted(loadInitialData)
/>
<section class="surface-panel table-panel" :aria-busy="loading">
<header class="panel-heading">
<div>
<h2>内部用户列表 <span class="secondary-text">共 {{ total }} 条</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="users" :columns="userColumns" preference-key="internal-users-table"
:loading="loading" :total="total" aria-label="内部用户列表" row-key="membershipId" empty-text="没有符合筛选条件的内部用户">
<template #toolbar-start><div><h2>内部用户列表 <span class="secondary-text">共 {{ total }} 条</span></h2><p>维护平台内部员工账号、平台角色和使用状态。</p></div></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></template>
<template #actions="{ row }">
<div class="table-actions">
<el-button v-if="canEdit || canChangeStatus || canResetPassword" link type="primary" @click="openEdit(row as PlatformManagedUser)">
@@ -38,6 +38,7 @@ function mountServerView() {
plugins: [pinia],
directives: { loading: () => undefined },
stubs: {
DataTable: { template: '<section><slot name="toolbar-start" /><slot name="toolbar-actions" /></section>' },
'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' },
'el-input': true,
'el-option': true,
+2 -1
View File
@@ -235,8 +235,9 @@ onMounted(loadPermissions)
<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" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
<template #toolbar-start><div><h2>权限资源树 <span class="secondary-text">共 {{ permissions.length }} 项</span></h2><p>默认展示目录与菜单,展开菜单后查看按钮权限;接口权限将在服务端契约确定后补充。</p></div></template>
<template #toolbar-actions><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></template>
<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>
+1
View File
@@ -36,6 +36,7 @@ function mountView(pinia = createPinia()) {
plugins: [pinia],
directives: { loading: () => undefined },
stubs: {
DataTable: { template: '<section><slot name="toolbar-start" /><slot name="toolbar-actions" /></section>' },
'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' },
'el-input': true,
'el-option': true,
+2 -1
View File
@@ -236,8 +236,9 @@ onMounted(loadRoles)
<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" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" empty-text="没有符合条件的角色">
<template #toolbar-start><div><h2>角色与授权 <span class="secondary-text">共 {{ filteredRows.length }} 条</span></h2><p>角色负责聚合菜单、操作和数据范围,内部用户可分配多个角色。</p></div></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></template>
<template #actions="{ row }"><div v-if="canEdit || canGrant || access.can('platform:role:status') || access.can('platform:role:delete')" class="table-actions"><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>
@@ -186,10 +186,11 @@ describe('InboundOrdersView', () => {
const toolbarRows = wrapper.find('.table-toolbar').element.children
expect(toolbarRows[0].classList.contains('table-tabs-row')).toBe(true)
expect(toolbarRows[1].classList.contains('inbound-stats-row')).toBe(true)
expect(toolbarRows[2].classList.contains('table-action-row')).toBe(true)
expect(toolbarRows).toHaveLength(2)
expect(wrapper.find('.inbound-stats-row').text()).toContain('当前显示 1 条 · 共 1 条')
expect(wrapper.find('.table-action-row').text()).toContain('所属企业 全部客户')
expect(wrapper.find('.table-action-buttons').text()).toContain('刷新')
const dataTableToolbar = wrapper.findComponent(DataTable).find('.data-table__toolbar')
expect(dataTableToolbar.text()).toContain('所属企业 全部客户')
expect(dataTableToolbar.text()).toContain('刷新')
expect(wrapper.text()).not.toContain('全部入库单')
expect(tabButtons[0].attributes('aria-selected')).toBe('true')
expect(tabButtons[0].attributes('tabindex')).toBe('0')
@@ -417,14 +417,10 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
<b>总体积 {{ formatAggregate(aggregateStats.volume) }} m³</b>
</span>
</div>
<div class="table-action-row">
<span class="result-summary">所属企业 {{ selectedCustomerName }}</span>
<div class="table-action-buttons">
<el-button :loading="loading" @click="loadInbounds"><RefreshCw :size="16" />刷新</el-button>
</div>
</div>
</header>
<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 #toolbar-start><div><h2>入库单列表</h2><p>所属企业 {{ selectedCustomerName }}</p></div></template>
<template #toolbar-actions><el-button :loading="loading" @click="loadInbounds"><RefreshCw :size="16" />刷新</el-button></template>
<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>
@@ -498,10 +494,6 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
.inbound-stats-row .total-info { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.inbound-stats-row b { font-weight: 650; }
.inbound-stats-row i { width: 1px; height: 12px; background: rgba(255, 255, 255, .38); }
.table-action-row { display: flex; min-width: 0; min-height: 48px; align-items: center; justify-content: space-between; gap: 16px; padding: 7px 14px; border-top: 1px solid #dfe6f0; background: #fbfcfe; }
.table-action-buttons { display: flex; min-width: 0; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.result-summary { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.table-action-buttons :deep(.el-button) { min-height: 34px; margin-left: 0; }
.table-panel :deep(.el-table th.el-table__cell) { border-right: 1px solid #edf0f4; }
.table-panel :deep(.el-table td.el-table__cell) { border-right: 1px solid #edf0f4; }
.primary-cell, .status-stack { display: grid; min-width: 0; justify-items: start; gap: 4px; }
@@ -514,5 +506,5 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
.detail-error :deep(.el-button) { margin-top: 14px; }
@media (max-width: 1180px) { .inbound-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 900px) { .inbound-tabs { width: 100%; } .inbound-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .inbound-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: 1fr; } .inbound-tab { padding-inline: 14px; } .table-tabs-row { padding: 0 10px; } .inbound-stats-row, .table-action-row { min-height: 0; align-items: stretch; flex-direction: column; gap: 10px; padding: 10px; } .inbound-stats-row .total-info { justify-content: flex-start; } .table-action-buttons { justify-content: flex-start; } .result-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .table-action-buttons :deep(.el-button) { min-height: 44px; } }
@media (max-width: 720px) { .inbound-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: 1fr; } .inbound-tab { padding-inline: 14px; } .table-tabs-row { padding: 0 10px; } .inbound-stats-row { min-height: 0; align-items: stretch; flex-direction: column; gap: 10px; padding: 10px; } .inbound-stats-row .total-info { justify-content: flex-start; } }
</style>
@@ -355,16 +355,6 @@ onMounted(() => {
</section>
<section v-else class="surface-panel table-panel" :aria-busy="loading">
<div class="partners-table-header">
<div class="partners-table-heading">
<h2>往来单位列表</h2>
<span>平台公共数据 · 共 {{ total }} 个往来单位</span>
</div>
<div class="partners-primary-actions">
<el-button v-if="access.can('platform:counterparty:import')" :disabled="loading" @click="openImport"><Upload :size="16" />批量导入</el-button>
<el-button v-if="access.can('platform:counterparty:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增往来单位</el-button>
</div>
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
@@ -378,6 +368,8 @@ onMounted(() => {
row-key="id"
empty-text="暂无符合条件的往来单位"
>
<template #toolbar-start><div class="partners-table-heading"><h2>往来单位列表</h2><span>平台公共数据 · 共 {{ total }} 个往来单位</span></div></template>
<template #toolbar-actions><div class="partners-primary-actions"><el-button v-if="access.can('platform:counterparty:import')" :disabled="loading" @click="openImport"><Upload :size="16" />批量导入</el-button><el-button v-if="access.can('platform:counterparty:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增往来单位</el-button></div></template>
<template #actions="{ row }">
<div class="table-actions">
<el-button link type="primary" @click="openDetail(row as PlatformPartnerResource)"><Eye :size="15" />详情</el-button>
@@ -457,7 +449,6 @@ onMounted(() => {
.partners-error { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 20px; border-color: #fecdca; background: #fffafa; }
.partners-error strong { color: #7a271a; font-size: 13px; }
.partners-error p { margin: 4px 0 0; color: #b54708; font-size: 12px; overflow-wrap: anywhere; }
.partners-table-header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; border-bottom: 1px solid var(--cc-border); }
.partners-table-heading { display: flex; min-width: 0; align-items: baseline; gap: 10px; }
.partners-table-heading h2 { margin: 0; color: var(--cc-text); font-size: 16px; line-height: 1.4; }
.partners-table-heading span { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
@@ -480,8 +471,7 @@ onMounted(() => {
@media (max-width: 720px) {
.partners-error { align-items: stretch; flex-direction: column; }
.partners-error .el-button { min-height: 44px; }
.partners-table-header { align-items: stretch; flex-direction: column; }
.partners-table-header .el-button { width: 100%; min-height: 40px; }
.partners-primary-actions :deep(.el-button) { width: 100%; min-height: 40px; }
.partners-primary-actions { flex-direction: column; }
.partner-detail { padding: 16px; }
.detail-grid { grid-template-columns: minmax(0, 1fr); }
@@ -283,16 +283,6 @@ onMounted(() => {
<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">
<div>
<h2>快递公司列表</h2>
<p>平台公共数据 · 共 {{ total }} 家快递公司</p>
</div>
<div class="panel-actions">
<el-button v-if="access.can('platform:express:import')" :disabled="loading" @click="openImport"><Upload :size="16" />批量导入</el-button>
<el-button v-if="access.can('platform:express:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增快递公司</el-button>
</div>
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
@@ -306,6 +296,8 @@ onMounted(() => {
row-key="id"
:empty-text="appliedFilters.keyword.trim() || appliedFilters.status ? '暂无符合筛选条件的快递公司' : '暂无快递公司档案'"
>
<template #toolbar-start><div><h2>快递公司列表</h2><p>平台公共数据 · 共 {{ total }} 家快递公司</p></div></template>
<template #toolbar-actions><div class="panel-actions"><el-button v-if="access.can('platform:express:import')" :disabled="loading" @click="openImport"><Upload :size="16" />批量导入</el-button><el-button v-if="access.can('platform:express:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增快递公司</el-button></div></template>
<template #actions="{ row }">
<div v-if="access.can('platform:express:edit') || access.can('platform:express:status')" class="table-actions">
<el-button
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
import { unifiedOrderStatusOptions } from '../api/unifiedOrders'
const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/pages/UnifiedOrdersView.vue'), 'utf8')
const routeSource = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/routes.ts'), 'utf8')
describe('UnifiedOrdersView contract', () => {
it('only exposes the seven product main statuses', () => {
@@ -57,12 +58,11 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('v-model:page-size="pageSize"')
})
it('keeps business actions as explicit C01/C03/C04/C06 boundaries', () => {
it('keeps only confirmed business actions as explicit boundaries', () => {
expect(source).toContain('nextActionBoundary')
expect(source).toContain('待接入:${label}')
expect(source).toContain('CONFIRM_PRELOAD')
expect(source).toContain("can('platform:firstleg:inbound:view')")
expect(source).toContain('openMeasurement')
expect(source).not.toContain('待接入:智能分柜')
expect(source).not.toContain('updateMainStatus')
expect(source).not.toContain('人工进入分柜')
expect(source).not.toContain('performPlatformUnifiedOrderAction')
@@ -85,6 +85,9 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('aria-label="状态历史"')
expect(source).toContain('aria-label="内部备注"')
expect(source).toContain('订单详情暂时无法加载')
expect(source).toContain('runMoreAction($event, row as UnifiedOrderResource)')
expect(source).toContain('aria-label="更多订单操作"')
expect(source).toContain("can('platform:order:note')")
})
it('renders order detail as a routed full page instead of a drawer', () => {
@@ -97,9 +100,10 @@ describe('UnifiedOrdersView contract', () => {
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)")
expect(source).toContain('后半程作业')
it('does not expose the unconfirmed downstream workflow', () => {
expect(source).not.toContain('后半程作业')
expect(source).not.toContain('openWorkflow')
expect(source).not.toContain('workflowEligible')
expect(routeSource).not.toContain('unified-order-workflow')
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, ClipboardList, Eye, History, ListChecks, PackageOpen, RefreshCw, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { ArrowLeft, Box, ChevronDown, ChevronUp, CircleCheck, Eye, History, ListChecks, MoreHorizontal, PackageOpen, RefreshCw, Ruler, SlidersHorizontal, StickyNote, WalletCards } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
@@ -80,6 +80,7 @@ const carrierForm = reactive({ carrierId: '' })
const noteOpen = ref(false)
const noteSaving = ref(false)
const noteContent = ref('')
const noteOrder = ref<UnifiedOrderResource | null>(null)
const showAdvancedFilters = ref(false)
const filters = ref<FilterModel>(emptyFilters())
const carrierRules: FormRules = {
@@ -305,19 +306,8 @@ 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: '完成测量',
SMART_SPLIT: '智能分柜',
CONFIRM_PRELOAD: '确认预装清单',
COMPLETE_LOADING: '确认装柜完成',
COMPLETE_DELIVERY: '确认派送完成',
}
function nextActionBoundary(row: UnifiedOrderResource) {
if (row.nextAction === 'APPROVE_ORDER') return ''
const label = row.nextAction ? nextActionLabels[row.nextAction] || row.nextAction : ''
return label ? `待接入:${label}` : ''
return row.nextAction === 'CONFIRM_MEASUREMENT' ? '待完成测量' : ''
}
function canApprove(row: UnifiedOrderResource) {
@@ -407,23 +397,29 @@ async function assignCarrierAndApprove() {
}
}
function openNoteDialog() {
if (!detail.value || !can('platform:order:note')) return
function openNoteDialog(order?: UnifiedOrderResource) {
if (!can('platform:order:note')) return
const target = order || detail.value?.summary || null
if (!target) return
noteOrder.value = target
noteContent.value = ''
noteOpen.value = true
}
async function saveNote() {
const content = noteContent.value.trim()
if (!detail.value || !content) {
if (!noteOrder.value || !content) {
ElMessage.warning('请填写内部备注')
return
}
noteSaving.value = true
try {
await addPlatformUnifiedOrderNote(String(detail.value.summary.tenantId), String(detail.value.summary.orderId), content)
const target = noteOrder.value
await addPlatformUnifiedOrderNote(String(target.tenantId), String(target.orderId), content)
noteOpen.value = false
await loadDetail()
if (detail.value?.summary.orderId === target.orderId) await loadDetail()
else await loadOrders()
noteOrder.value = null
ElMessage.success('内部备注已保存')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '内部备注保存失败,请稍后重试').message)
@@ -533,17 +529,20 @@ function openMeasurement(row: UnifiedOrderResource) {
void router.push({ name: 'inbound-order-detail', params: { inboundId: String(row.inboundId) }, query: { tenantId: String(row.tenantId) } })
}
function workflowEligible(row: UnifiedOrderResource) {
return Boolean(row.inboundId && ['WAIT_SPLIT', 'LOADING', 'LOADED'].includes(row.mainStatus))
function openInboundFromDetail() {
const summary = detail.value?.summary
if (!summary?.inboundId || !can('platform:firstleg:inbound:view')) return
void router.push({
name: 'inbound-order-detail',
params: { inboundId: String(summary.inboundId) },
query: { tenantId: String(summary.tenantId) },
})
}
function openWorkflow(row: UnifiedOrderResource) {
if (!workflowEligible(row) || !can('platform:firstleg:inbound:view')) return
void router.push({
name: 'unified-order-workflow',
params: { orderId: String(row.orderId) },
query: { tenantId: String(row.tenantId) },
})
function runMoreAction(command: string | number | object, row: UnifiedOrderResource) {
if (command === 'note') openNoteDialog(row)
if (command === 'approve') openApproval(row)
if (command === 'measurement') openMeasurement(row)
}
function closeDetail() {
@@ -590,18 +589,19 @@ onMounted(() => {
<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>
<el-button link class="back-button" @click="closeDetail"><ArrowLeft :size="16" />返回列表</el-button>
<span class="order-mark" aria-hidden="true"><Box :size="19" /></span>
<div class="order-title">
<span>入仓码</span>
<h1>{{ detail?.summary.inboundCode || '暂未生成' }}</h1>
<h1>
<el-button v-if="detail?.summary.inboundId && can('platform:firstleg:inbound:view')" link type="primary" class="inbound-code-link" @click="openInboundFromDetail">{{ detail.summary.inboundCode || `入库单 ${detail.summary.inboundId}` }}</el-button>
<span v-else>{{ detail?.summary.inboundCode || '暂未生成' }}</span>
</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">
<div v-if="detail && canApprove(detail.summary)" 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>
@@ -669,7 +669,7 @@ onMounted(() => {
</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>
<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>
@@ -710,15 +710,24 @@ onMounted(() => {
<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" 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>
<template #toolbar-start><div><h2>统一订单列表</h2><p>共 {{ total }} 条订单</p></div></template>
<template #toolbar-actions>
<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>
<el-button v-if="canApprove(row as UnifiedOrderResource)" link type="success" :loading="approvalLoadingId === String((row as UnifiedOrderResource).orderId)" @click="openApproval(row as UnifiedOrderResource)"><CircleCheck :size="15" />审核通过</el-button>
<el-button v-if="(row as UnifiedOrderResource).mainStatus === 'WAIT_MEASURE' && (row as UnifiedOrderResource).inboundId && can('platform:firstleg:inbound:view')" link type="primary" @click="openMeasurement(row as UnifiedOrderResource)">测量作业</el-button>
<el-button v-if="workflowEligible(row as UnifiedOrderResource) && can('platform:firstleg:inbound:view')" link type="primary" @click="openWorkflow(row as UnifiedOrderResource)"><ClipboardList :size="15" />后半程作业</el-button>
<el-dropdown trigger="click" @command="runMoreAction($event, row as UnifiedOrderResource)">
<el-button link type="primary" aria-label="更多订单操作"><MoreHorizontal :size="17" />更多</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="can('platform:order:note')" command="note"><StickyNote :size="15" />添加备注</el-dropdown-item>
<el-dropdown-item v-if="canApprove(row as UnifiedOrderResource)" command="approve"><CircleCheck :size="15" />审核通过</el-dropdown-item>
<el-dropdown-item v-if="(row as UnifiedOrderResource).mainStatus === 'WAIT_MEASURE' && (row as UnifiedOrderResource).inboundId && can('platform:firstleg:inbound:view')" command="measurement">测量作业</el-dropdown-item>
<el-dropdown-item v-if="!can('platform:order:note') && !canApprove(row as UnifiedOrderResource) && !((row as UnifiedOrderResource).inboundId && can('platform:firstleg:inbound:view'))" disabled>暂无可用操作</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<span v-if="nextActionBoundary(row as UnifiedOrderResource)" class="action-boundary">{{ nextActionBoundary(row as UnifiedOrderResource) }}</span>
</div>
</template>
@@ -784,14 +793,15 @@ 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; }
.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-detail-header { display: flex; min-width: 0; min-height: 42px; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
.order-detail-heading { display: flex; min-width: 0; align-items: center; gap: 10px; }
.back-button { flex: 0 0 auto; padding: 0; }
.order-mark { display: grid; width: 36px; height: 36px; flex: 0 0 36px; place-items: center; border-radius: 7px; 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; }
.order-title h1 { margin: 0; overflow: hidden; color: #172033; font-size: 18px; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
.inbound-code-link { width: 100%; max-width: 100%; overflow: hidden; justify-content: flex-start; padding: 0; color: var(--cc-primary); font: inherit; font-weight: 700; vertical-align: baseline; }
.inbound-code-link :deep(span) { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.order-title p { margin: 2px 0 0; color: var(--cc-muted); font-size: 11px; }
.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; }
@@ -835,6 +845,6 @@ onMounted(() => {
.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: 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; } }
@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 36px minmax(0, 1fr) auto; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .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: auto minmax(0, 1fr) auto; } .order-mark { display: none; } }
</style>
-11
View File
@@ -12,17 +12,6 @@ export const unifiedOrderRoutes = [
permissionAnyOf: ['platform:firstleg:inbound:list'],
},
},
{
path: 'orders/:orderId/workflow',
name: 'unified-order-workflow',
component: () => import('./workflows/pages/UnifiedOrderWorkflowView.vue'),
meta: {
title: '订单后半程作业',
description: '处理分柜、预装、装柜和派送作业',
permission: 'platform:firstleg:inbound:view',
permissionAnyOf: ['platform:firstleg:inbound:list', 'platform:order:list'],
},
},
{
path: 'orders/:orderId',
name: 'unified-order-detail',
@@ -188,11 +188,11 @@ describe('LocationsView', () => {
const wrapper = await mountView()
await selectScope(wrapper)
const panelHeading = wrapper.find('.table-panel .panel-heading')
expect(panelHeading.exists()).toBe(true)
expect(panelHeading.text()).toContain('库位列表')
expect(panelHeading.text()).toContain('共 2 个库位')
expect(panelHeading.text()).toContain('新增库位')
const tableToolbar = wrapper.find('.table-panel .data-table__toolbar')
expect(tableToolbar.exists()).toBe(true)
expect(tableToolbar.text()).toContain('库位列表')
expect(tableToolbar.text()).toContain('共 2 个库位')
expect(tableToolbar.text()).toContain('新增库位')
expect(wrapper.findComponent(QueryToolbar).text()).not.toContain('新增库位')
expect(listAllPlatformWarehouses).toHaveBeenCalledWith({ warehouseType: 'DOMESTIC' })
expect(listPlatformLocations).toHaveBeenLastCalledWith({
@@ -213,7 +213,7 @@ describe('LocationsView', () => {
it('opens creation without a list filter and requires the warehouse inside the form', async () => {
const wrapper = await mountView()
const createButton = wrapper.find('.table-panel .panel-heading').findAll('button').find((button) => button.text().trim() === '新增库位')
const createButton = wrapper.find('.table-panel .data-table__toolbar').findAll('button').find((button) => button.text().trim() === '新增库位')
expect(createButton?.attributes('disabled')).toBeUndefined()
await createButton!.trigger('click')
@@ -229,7 +229,7 @@ describe('LocationsView', () => {
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { ...(toolbar.props('modelValue') as QueryModel), warehouseId: fbaWarehouse.id })
await flushPromises()
const createButton = wrapper.find('.table-panel .panel-heading').findAll('button').find((button) => button.text().trim() === '新增库位')
const createButton = wrapper.find('.table-panel .data-table__toolbar').findAll('button').find((button) => button.text().trim() === '新增库位')
await createButton!.trigger('click')
await flushPromises()
@@ -514,14 +514,6 @@ onMounted(() => {
</el-alert>
<section class="surface-panel table-panel" aria-label="库位列表区域">
<div class="panel-heading">
<div><h2>库位列表</h2><p>共 {{ total }} 个库位</p></div>
<el-button
v-if="access.can('platform:location:create')"
type="primary"
@click="openCreate"
><Plus :size="16" />新增库位</el-button>
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
@@ -535,6 +527,8 @@ onMounted(() => {
aria-label="库位列表"
:empty-text="emptyDescription"
>
<template #toolbar-start><div><h2>库位列表</h2><p>共 {{ total }} 个库位</p></div></template>
<template #toolbar-actions><el-button v-if="access.can('platform:location:create')" type="primary" @click="openCreate"><Plus :size="16" />新增库位</el-button></template>
<template #actions="{ row }">
<div class="table-actions location-actions">
<el-button link type="primary" :disabled="loading" @click="loadDetail(row as PlatformLocationResource, 'view')">
@@ -380,10 +380,6 @@ onMounted(() => {
/>
<section class="table-panel surface-panel" aria-label="托盘列表区域">
<div class="panel-heading">
<div><h2>托盘列表</h2><p>共 {{ total }} 个托盘</p></div>
<el-button v-if="access.can('platform:pallet:create')" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button>
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
@@ -396,6 +392,8 @@ onMounted(() => {
:empty-text="filtered ? '暂无符合筛选条件的托盘' : '暂无托盘数据'"
aria-label="托盘列表"
>
<template #toolbar-start><div><h2>托盘列表</h2><p>共 {{ total }} 个托盘</p></div></template>
<template #toolbar-actions><el-button v-if="access.can('platform:pallet:create')" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }">
<div class="table-actions pallet-row-actions">
<el-button link type="primary" title="查看托盘" @click="openDetail(row as PlatformPalletResource)"><Eye :size="15" />查看</el-button>
@@ -530,9 +528,6 @@ onMounted(() => {
.pallet-page { display: grid; min-width: 0; gap: 16px; }
.surface-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.table-panel { min-width: 0; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 18px; border-bottom: 1px solid var(--cc-border); }
.panel-heading h2 { margin: 0; font-size: 16px; }
.panel-heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
.context-empty { padding: 56px 20px; color: var(--cc-muted); text-align: center; }
.pallet-row-actions { gap: 8px; white-space: nowrap; }
.pallet-row-actions :deep(.el-button + .el-button) { margin-left: 0; }
@@ -579,8 +574,6 @@ onMounted(() => {
@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; }
.editor-form { padding-right: 16px; padding-left: 16px; }
.editor-field-grid { grid-template-columns: 1fr; }
+10 -19
View File
@@ -675,25 +675,6 @@ onMounted(() => void loadWarehouses())
<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">
<div>
<h2>公共仓库</h2>
<p>国内收货仓、FBA 地址和海外仓统一维护 · 共 {{ total }} 个</p>
</div>
<div v-if="access.can('platform:warehouse:create')" class="warehouse-primary-actions">
<el-button :disabled="!warehouseReady" @click="openImport"><Upload :size="16" />导入仓库</el-button>
<el-dropdown trigger="click" :disabled="!warehouseReady" @command="openCreate($event as WarehouseType)">
<el-button type="primary" :disabled="!warehouseReady"><Plus :size="16" />新增仓库<ChevronDown :size="15" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="FBA"><FileSpreadsheet :size="15" />新增 FBA 地址</el-dropdown-item>
<el-dropdown-item command="DOMESTIC"><WarehouseIcon :size="15" />新增国内仓</el-dropdown-item>
<el-dropdown-item command="OVERSEAS"><MapPin :size="15" />新增海外仓</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
@@ -707,6 +688,16 @@ onMounted(() => void loadWarehouses())
aria-label="公共仓库列表"
:empty-text="loaded ? '暂无符合筛选条件的仓库' : '仓库数据暂不可用,请重新加载'"
>
<template #toolbar-start><div><h2>公共仓库</h2><p>国内收货仓、FBA 地址和海外仓统一维护 · 共 {{ total }} 个</p></div></template>
<template #toolbar-actions>
<div v-if="access.can('platform:warehouse:create')" class="warehouse-primary-actions">
<el-button :disabled="!warehouseReady" @click="openImport"><Upload :size="16" />导入仓库</el-button>
<el-dropdown trigger="click" :disabled="!warehouseReady" @command="openCreate($event as WarehouseType)">
<el-button type="primary" :disabled="!warehouseReady"><Plus :size="16" />新增仓库<ChevronDown :size="15" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item command="FBA"><FileSpreadsheet :size="15" />新增 FBA 地址</el-dropdown-item><el-dropdown-item command="DOMESTIC"><WarehouseIcon :size="15" />新增国内仓</el-dropdown-item><el-dropdown-item command="OVERSEAS"><MapPin :size="15" />新增海外仓</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
<template #actions="{ row }">
<div v-if="access.can('platform:warehouse:edit') || access.can('platform:warehouse:status')" class="table-actions">
<el-button v-if="access.can('platform:warehouse:edit')" link type="primary" :disabled="!warehouseReady || editLoading" @click="openEdit(row as WarehouseRow)"><Pencil :size="15" />编辑</el-button>
+28
View File
@@ -87,4 +87,32 @@ describe('DataTable column preferences', () => {
await wrapper.vm.$nextTick()
expect(wrapper.findAll('.column-stub').map((column) => column.attributes('data-label'))).toEqual(['操作', '名称', '状态'])
})
it('keeps business actions, column settings, and trailing controls in one toolbar', () => {
const wrapper = shallowMount(DataTable, {
props: { data: [], columns: configurableColumns, preferenceKey: 'orders-table' },
slots: {
'toolbar-start': '<h2>订单列表</h2>',
'toolbar-actions': '<button>新增订单</button>',
'toolbar-end': '<button>刷新</button>',
},
global: {
provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') },
stubs: {
ElTable: TableStub,
ElTableColumn: ColumnStub,
ElButton: ButtonStub,
ElPopover: PopoverStub,
},
directives: { loading: {} },
},
})
expect(wrapper.findAll('.data-table__toolbar')).toHaveLength(1)
expect(wrapper.get('.data-table__toolbar-start').text()).toBe('订单列表')
expect(wrapper.get('.data-table__toolbar-actions').text()).toContain('新增订单')
expect(wrapper.get('.data-table__toolbar-actions').text()).toContain('刷新')
expect(wrapper.get('.data-table__business-actions').text()).toBe('新增订单')
expect(wrapper.get('.data-table__toolbar-end').text()).toBe('刷新')
})
})
+25 -17
View File
@@ -250,20 +250,24 @@ function setPage(value: number) {
<template>
<div class="data-table">
<div v-if="$slots['toolbar-start'] || preferenceEnabled" class="data-table__toolbar">
<div v-if="$slots['toolbar-start'] || $slots['toolbar-actions'] || preferenceEnabled || $slots['toolbar-end']" 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 class="data-table__toolbar-actions">
<div v-if="$slots['toolbar-actions']" class="data-table__business-actions"><slot name="toolbar-actions" /></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 v-if="$slots['toolbar-end']" class="data-table__toolbar-end"><slot name="toolbar-end" /></div>
</div>
</div>
<el-table ref="tableRef" v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :aria-busy="loading">
<el-table-column
@@ -319,9 +323,13 @@ 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__toolbar { display: flex; min-height: 72px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); }
.data-table__toolbar-start { min-width: 0; flex: 1; }
.data-table__toolbar-start :deep(h2) { margin: 0; color: var(--cc-text); font-size: 16px; line-height: 1.4; }
.data-table__toolbar-start :deep(p) { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.data-table__toolbar-actions { display: flex; flex: 0 0 auto; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.data-table__business-actions, .data-table__toolbar-end { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.data-table__toolbar-actions :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; }
@@ -330,5 +338,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__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; } }
@media (max-width: 720px) { .data-table__toolbar { align-items: stretch; flex-direction: column; padding: 14px; } .data-table__toolbar-actions { width: 100%; align-items: flex-start; flex-wrap: nowrap; justify-content: flex-start; } .data-table__business-actions { flex: 1 1 auto; justify-content: flex-start; } .data-table__toolbar-end { flex: 0 1 auto; justify-content: flex-start; } .data-table__toolbar-actions :deep(.el-button) { min-height: 40px; } .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,14 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/LayoutPreferenceList.vue'), 'utf8')
describe('LayoutPreferenceList overflow layout', () => {
it('keeps long item collections inside a viewport-bounded scroll area', () => {
expect(source).toContain('grid-template-rows: auto minmax(0, 1fr) auto')
expect(source).toContain('max-height: min(520px, calc(100dvh - 120px))')
expect(source).toContain('.layout-preference-list__scroll { min-height: 0;')
expect(source).toContain('overflow-y: auto')
})
})
+32 -29
View File
@@ -65,41 +65,45 @@ watch(() => props.items.map((item) => item.key), syncSortableOrder)
<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>
<div class="layout-preference-list__scroll" tabindex="0" aria-label="布局项目">
<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>
</div>
<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 { display: grid; max-height: min(360px, calc(100vh - 180px)); max-height: min(360px, calc(100dvh - 180px)); grid-template-rows: auto minmax(0, 1fr) auto; gap: 10px; overflow: hidden; }
.layout-preference-list__hint { margin: 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.layout-preference-list__scroll { min-height: 0; padding-right: 4px; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
.layout-preference-list__scroll:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 1px; }
.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__items.is-locked { margin-bottom: 4px; 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; }
@@ -113,4 +117,3 @@ watch(() => props.items.map((item) => item.key), syncSortableOrder)
.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>