Release/test 合并上生产 #2
@@ -50,7 +50,7 @@
|
||||
| 国内整柜业务 | `modules/domestic-containers/` | Backend `warehouse/domesticcontainer/`;OpenAPI `warehouse/src/main/resources/openapi/domestic-container-v1.yaml`;核对入库、出库、库存详情和幂等 |
|
||||
| 客户运营指标与操作日志 | 客户详情指标在 `modules/customers/`;日志组件与 API 在 `modules/audit/` | 统计联查 Backend `firstleg/customerstatistics/`;审计查询联查 `audit-service/`,列表与详情使用 `platform:audit:list` |
|
||||
| 平台用户与 RBAC | `modules/iam/`、`core/access/` | `auth/`、`iam/` |
|
||||
| 平台消息管理、模板与我的消息 | `modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;接口契约 `iam/src/main/resources/openapi/notification-v1.yaml`,触发消息的业务事件仍联查对应业务服务 |
|
||||
| 平台消息管理与模板 | `modules/notifications/` | 仅通过顶部“消息中心”进入,不进入左侧菜单;“我的消息”仅属于 Client。Backend `iam/src/main/java/com/baiyue/iam/notification/`;接口契约 `iam/src/main/resources/openapi/notification-v1.yaml`,触发消息的业务事件仍联查对应业务服务 |
|
||||
| 导航与原型菜单图标 | `core/navigation/adminNavigation.ts`、`core/navigation/prototypeMenuIcons.ts` | 菜单结构、权限与路由同步核对;已有原型图标优先使用映射资源 |
|
||||
| 平台仓库与仓储资源 | `modules/warehouses/`、`modules/warehouse-resources/` | `warehouse/` |
|
||||
| 订单列表、详情与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;费用入口 `modules/unified-orders/components/InboundFeeDetailManager.vue`、`modules/unified-orders/api/inboundFeeDetails.ts` | `order/`、`firstleg/`;费用来源审核发布联查 `firstleg/AGENTS.md` |
|
||||
|
||||
@@ -55,6 +55,8 @@ describe('AppLayout', () => {
|
||||
expect(wrapper.text()).toContain('消息管理')
|
||||
expect(wrapper.text()).toContain('模板管理')
|
||||
expect(wrapper.text()).not.toContain('我的消息')
|
||||
const notificationTrigger = wrapper.get('.notification-trigger')
|
||||
expect(notificationTrigger.find('.notification-count').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the shell heading when the route opts out', async () => {
|
||||
|
||||
@@ -168,7 +168,9 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
||||
|
||||
<style scoped>
|
||||
.app-header--without-page-heading .header-user { margin-left: auto; }
|
||||
.notification-trigger { display: inline-flex; min-height: 34px; align-items: center; gap: 6px; padding: 0 9px; border: 0; border-radius: 4px; background: transparent; color: var(--cc-muted); font: inherit; }
|
||||
.notification-trigger { cursor: pointer; }
|
||||
@media (max-width: 720px) { .notification-trigger > span { display: none; } }
|
||||
.notification-trigger { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 12px; border: 1px solid rgba(255, 255, 255, .28); border-radius: 10px; background: rgba(255, 255, 255, .12); color: #fff; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .2s ease, border-color .2s ease, transform .2s ease; }
|
||||
.notification-trigger:hover { border-color: rgba(255, 255, 255, .48); background: rgba(255, 255, 255, .24); transform: translateY(-1px); }
|
||||
.notification-trigger:focus-visible { outline: 2px solid rgba(255, 255, 255, .9); outline-offset: 2px; }
|
||||
.notification-trigger > svg { flex: 0 0 auto; }
|
||||
@media (max-width: 720px) { .notification-trigger { width: 44px; height: 44px; justify-content: center; padding: 0; } .notification-trigger > span { display: none; } }
|
||||
</style>
|
||||
|
||||
@@ -9,14 +9,106 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getDomesticContainerInventory } from '../api/domesticContainers'
|
||||
import type { InventoryDetail, TransactionRecord } from '../types'
|
||||
import type { InventoryDetail, InventoryStatus, TransactionRecord } from '../types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<InventoryDetail | null>(null)
|
||||
|
||||
const transactionColumns: DataTableColumn[] = [
|
||||
{ prop: 'businessType', label: '业务类型', width: 110, slotName: 'businessType' },
|
||||
{ prop: 'orderNo', label: '单号', minWidth: 210 },
|
||||
{ prop: 'quantity', label: '数量', width: 110, align: 'right', formatter: (row: TransactionRecord) => `${row.quantity} 箱` },
|
||||
{ prop: 'operationTime', label: '操作时间', width: 170, formatter: (row: TransactionRecord) => displayTime(row.operationTime) },
|
||||
{ prop: 'remark', label: '备注', minWidth: 180, formatter: (row: TransactionRecord) => row.remark || '—' },
|
||||
]
|
||||
|
||||
function inventoryStatus(status: InventoryStatus) {
|
||||
return ({ IN_STOCK: '在库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUND: '已出库' } as const)[status]
|
||||
}
|
||||
|
||||
function displayTime(value: string) {
|
||||
return value ? value.replace('T', ' ').slice(0, 16) : '—'
|
||||
}
|
||||
|
||||
function back() {
|
||||
void router.push({ name: 'domestic-containers' })
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
detail.value = await getDomesticContainerInventory(String(route.params.inventoryId))
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '整柜库存详情加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const detail = ref<InventoryDetail | null>(null)
|
||||
const transactionColumns: DataTableColumn[] = [{ prop: 'businessType', label: '类型', width: 100, slotName: 'businessType' }, { prop: 'orderNo', label: '单号', minWidth: 160 }, { prop: 'quantity', label: '数量', width: 100, align: 'right', formatter: (row: TransactionRecord) => `${row.quantity} 箱` }, { prop: 'operationTime', label: '操作时间', minWidth: 170, formatter: (row: TransactionRecord) => row.operationTime.replace('T', ' ').slice(0, 16) }, { prop: 'remark', label: '备注', minWidth: 180, formatter: (row: TransactionRecord) => row.remark || '—' }]
|
||||
function inventoryStatus(status: string) { return ({ IN_STOCK: '在库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUND: '已出库' } as Record<string, string>)[status] || status }
|
||||
async function load() { loading.value = true; error.value = null; try { detail.value = await getDomesticContainerInventory(String(route.params.inventoryId)) } catch (cause) { error.value = getApiErrorDetails(cause, '整柜库存详情加载失败') } finally { loading.value = false } }
|
||||
function back() { void router.push({ name: 'domestic-containers' }) }
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template><AdminPage as="div" class="inventory-detail-page"><header class="detail-header"><div><el-button aria-label="返回整柜业务" @click="back"><ArrowLeft :size="16" />返回</el-button><h1>{{ detail?.inventory.containerNo || '整柜库存详情' }}</h1><StatusBadge v-if="detail" :status="inventoryStatus(detail.inventory.inventoryStatus)" /></div><p>查看整柜库存快照与不可变交易记录</p></header><LoadErrorState v-if="error" title="整柜库存详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><template v-else-if="detail"><section class="detail-grid"><div class="surface-panel detail-section"><h2>货物信息</h2><dl><div><dt>货物名称</dt><dd>{{ detail.inventory.cargoName }}</dd></div><div><dt>集装箱号</dt><dd>{{ detail.inventory.containerNo }}</dd></div><div><dt>国内仓</dt><dd>{{ detail.inventory.warehouseName }}</dd></div><div><dt>入库单数量</dt><dd>{{ detail.inboundOrderCount }}</dd></div><div><dt>出库单数量</dt><dd>{{ detail.outboundOrderCount }}</dd></div><div><dt>累计入库体积</dt><dd>{{ detail.inventory.inboundVolumeCbm.toFixed(2) }} m³</dd></div></dl></div><div class="surface-panel detail-section"><h2>库存信息</h2><dl><div><dt>入库箱数</dt><dd>{{ detail.inventory.inboundBoxes }} 箱</dd></div><div><dt>已出库箱数</dt><dd>{{ detail.inventory.outboundBoxes }} 箱</dd></div><div><dt>当前库存</dt><dd>{{ detail.inventory.availableBoxes }} 箱</dd></div><div><dt>库存状态</dt><dd>{{ inventoryStatus(detail.inventory.inventoryStatus) }}</dd></div><div><dt>最后更新时间</dt><dd>{{ detail.inventory.updatedAt.replace('T', ' ').slice(0, 16) }}</dd></div><div><dt>库存版本</dt><dd>{{ detail.inventory.version }}</dd></div></dl></div></section><section class="surface-panel table-panel transactions"><h2>进销存交易记录</h2><DataTable :data="detail.transactions" :columns="transactionColumns" row-key="id" stripe column-settings-mode="hide" empty-text="暂无进销存流水"><template #businessType="{ row }"><StatusBadge :status="row.businessType === 'INBOUND' ? '入库' : '出库'" /></template></DataTable></section></template><div v-else class="surface-panel detail-loading" v-loading="loading">正在加载整柜库存详情</div></AdminPage></template>
|
||||
<style scoped>.inventory-detail-page{display:grid;min-width:0;gap:16px}.detail-header{padding:18px 20px;background:#fff;border:1px solid var(--cc-border)}.detail-header>div{display:flex;align-items:center;flex-wrap:wrap;gap:12px}.detail-header h1{margin:0;font-size:20px;letter-spacing:0}.detail-header p{margin:10px 0 0;color:var(--cc-muted)}.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.detail-section{padding:18px}.detail-section h2,.transactions h2{margin:0 0 16px;font-size:16px;letter-spacing:0}.detail-section dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0;border-top:1px solid var(--cc-border);border-left:1px solid var(--cc-border)}.detail-section dl>div{min-width:0;padding:12px;border-right:1px solid var(--cc-border);border-bottom:1px solid var(--cc-border)}dt{font-size:12px;color:var(--cc-muted)}dd{margin:5px 0 0;overflow-wrap:anywhere}.transactions{padding:18px}.detail-loading{min-height:180px;padding:24px;color:var(--cc-muted)}@media(max-width:720px){.detail-grid{grid-template-columns:1fr}}@media(max-width:360px){.detail-header,.detail-section,.transactions{padding:12px}.detail-section dl{grid-template-columns:1fr}.detail-header h1{font-size:18px;width:100%}}</style>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="inventory-detail-page">
|
||||
<header class="detail-header">
|
||||
<el-button aria-label="返回整柜业务" @click="back"><ArrowLeft :size="16" />返回</el-button>
|
||||
<h1>{{ detail?.inventory.containerNo || '整柜库存详情' }}</h1>
|
||||
<StatusBadge v-if="detail" :status="inventoryStatus(detail.inventory.inventoryStatus)" />
|
||||
</header>
|
||||
|
||||
<LoadErrorState v-if="error" title="整柜库存详情加载失败" :message="error.message" :loading="loading" @retry="load">
|
||||
<template #details><ApiErrorMeta :details="error" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<section class="detail-section" aria-labelledby="inventory-basic-heading">
|
||||
<h2 id="inventory-basic-heading">整柜信息</h2>
|
||||
<dl class="detail-fields">
|
||||
<div><dt>货物名称</dt><dd>{{ detail.inventory.cargoName }}</dd></div>
|
||||
<div><dt>集装箱号</dt><dd>{{ detail.inventory.containerNo }}</dd></div>
|
||||
<div><dt>国内仓</dt><dd>{{ detail.inventory.warehouseName }}</dd></div>
|
||||
<div><dt>库存状态</dt><dd>{{ inventoryStatus(detail.inventory.inventoryStatus) }}</dd></div>
|
||||
<div><dt>入库单数量</dt><dd>{{ detail.inboundOrderCount }}</dd></div>
|
||||
<div><dt>出库单数量</dt><dd>{{ detail.outboundOrderCount }}</dd></div>
|
||||
<div><dt>入库箱数</dt><dd>{{ detail.inventory.inboundBoxes }} 箱</dd></div>
|
||||
<div><dt>已出库箱数</dt><dd>{{ detail.inventory.outboundBoxes }} 箱</dd></div>
|
||||
<div><dt>当前库存</dt><dd>{{ detail.inventory.availableBoxes }} 箱</dd></div>
|
||||
<div><dt>累计入库体积</dt><dd>{{ detail.inventory.inboundVolumeCbm.toFixed(2) }} m³</dd></div>
|
||||
<div><dt>最后更新时间</dt><dd>{{ displayTime(detail.inventory.updatedAt) }}</dd></div>
|
||||
<div><dt>库存版本</dt><dd>{{ detail.inventory.version }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="transactions" aria-labelledby="transactions-heading">
|
||||
<h2 id="transactions-heading">进销存交易记录</h2>
|
||||
<DataTable :data="detail.transactions" :columns="transactionColumns" row-key="id" stripe column-settings-mode="hide" empty-text="暂无进销存流水">
|
||||
<template #businessType="{ row }"><StatusBadge :status="row.businessType === 'INBOUND' ? '入库' : '出库'" /></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else class="detail-loading" v-loading="loading">正在加载整柜库存详情</div>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inventory-detail-page { display: grid; min-width: 0; gap: 16px; }
|
||||
.detail-header { display: flex; align-items: center; gap: 12px; min-height: 40px; }
|
||||
.detail-header h1 { margin: 0; font-size: 20px; letter-spacing: 0; }
|
||||
.detail-section, .transactions { min-width: 0; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.detail-section { padding: 16px 18px 18px; }
|
||||
.detail-section h2, .transactions h2 { margin: 0; font-size: 15px; letter-spacing: 0; }
|
||||
.detail-fields { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 14px 0 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
|
||||
.detail-fields > div { min-width: 0; min-height: 58px; padding: 10px 12px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-fields dt { color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-fields dd { margin: 4px 0 0; overflow-wrap: anywhere; }
|
||||
.transactions { padding-top: 16px; overflow: hidden; }
|
||||
.transactions h2 { margin: 0 18px 14px; }
|
||||
.detail-loading { min-height: 180px; padding: 24px; border: 1px solid var(--cc-border); background: #fff; color: var(--cc-muted); }
|
||||
@media (max-width: 960px) { .detail-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 520px) { .detail-header { flex-wrap: wrap; } .detail-header h1 { width: 100%; font-size: 18px; } .detail-fields { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -68,4 +68,4 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
|
||||
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></template><template v-if="loadError" #state><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template><section v-if="!loadError" class="surface-panel table-panel"><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'"><template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><el-button size="small" type="primary" plain @click="viewInventory(row as ContainerOrder | InventoryRecord)"><Eye :size="15" />查看</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></section></ListPageLayout>
|
||||
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit"><LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState><el-form ref="formRef" :model="currentForm" :rules="rules" label-position="top"><el-row :gutter="16"><template v-if="dialogType === 'inbound'"><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :precision="0" controls-position="right" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></el-col></template><template v-else><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称"><el-input v-model="outboundForm.cargoName" readonly /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :precision="0" controls-position="right" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="可用库存"><el-input :model-value="`${outboundForm.availableBoxes} 箱`" readonly /></el-form-item></el-col><el-col v-if="candidateError" :xs="24"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></el-col></template><el-col :xs="24" :sm="12"><el-form-item :label="dialogType === 'inbound' ? '入库时间' : '出库时间'" prop="operationTime"><el-date-picker v-model="currentForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template>
|
||||
|
||||
<style scoped>.domestic-containers-page{min-width:0}.business-tabs{margin-bottom:16px;background:#fff;border:1px solid var(--cc-border);padding:0 16px}.inventory-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;margin:0 0 12px;background:var(--cc-border);border:1px solid var(--cc-border)}.inventory-summary>div{display:grid;gap:6px;padding:14px 16px;background:#fff}.inventory-summary span{font-size:12px;color:var(--cc-muted)}.inventory-summary strong{font-size:20px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:360px){.business-tabs{padding:0 8px}.business-tabs :deep(.el-tabs__item){padding:0 8px;font-size:13px}.inventory-summary{grid-template-columns:1fr}.inventory-summary strong{font-size:18px}}</style>
|
||||
<style scoped>.domestic-containers-page{min-width:0}.business-tabs{margin-bottom:16px}.business-tabs :deep(.el-tabs__header){margin:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 16px 12px;border:1px solid var(--cc-border);background:#f8fafc}.inventory-summary>div{display:flex;align-items:baseline;gap:8px;min-width:0;padding:9px 16px;border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:12px}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.business-tabs :deep(.el-tabs__item){padding:0 8px;font-size:13px}.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
|
||||
|
||||
@@ -14,9 +14,7 @@
|
||||
| 模板管理 | 新增模板 | `modal-msg-template-create`,约 760px 居中弹框 | 760px `AppDialog`;CODE、名称、分类、标题/正文、变量白名单、预览;保存为 DRAFT | 仅支持 `IN_APP`,不展示真实渠道选择 |
|
||||
| 模板管理 | 编辑模板 | 原型仅有编辑按钮,未定义容器 | 复用同尺寸模板弹框,仅 DRAFT 可编辑,CODE 不可变并携带 version | 冻结契约明确只允许编辑草稿,复用表单避免重复交互 |
|
||||
| 模板管理 | 状态流转 | 原型未定义 | DRAFT 可激活,ACTIVE 可禁用,DISABLED 无操作 | 严格执行 `DRAFT -> ACTIVE -> DISABLED`;禁用后不可恢复 |
|
||||
| 我的消息 | 列表/查询 | `page-msg-my` 页面内表格 | `/notifications/mine` 查询当前平台员工收件箱,支持状态、分类筛选;操作列固定第一列 | 使用 `platform` app,服务端从登录上下文隔离,不传企业 ID |
|
||||
| 我的消息 | 查看/标为已读 | 原型仅有查看按钮;支持单条和全部已读 | 680px 居中详情弹框;打开未读详情自动标记;支持单条和全部标为已读 | 对接 detail/read/read-all;变更后刷新顶部未读数 |
|
||||
| 我的消息 | 清空已读 | 原型列表按钮 | 不展示 | 首期没有删除或清空已读 API,不能展示可点击后报错的入口 |
|
||||
| 顶部入口 | 未读入口/三页跳转 | `topMessageMenu` 顶部下拉 | 有收件箱权限时加载真实未读数;按任务/模板权限展示管理入口,始终提供“我的消息” | 对接 `/platform/v1/notifications/unread-count`;加载失败显示异常标记并保留可访问入口 |
|
||||
| 我的消息 | 页面归属 | 原型保留 `page-msg-my` 历史页面 | Admin 不实现、不注册路由 | 产品确认“我的消息”仅属于企业客户端 |
|
||||
| 顶部入口 | 管理入口 | `topMessageMenu` 顶部下拉,左侧无消息菜单 | 按任务/模板权限展示“消息管理”“模板管理” | Admin 仅保留后台管理能力,不展示个人收件箱与未读数 |
|
||||
|
||||
接口依赖:所有响应经 `{code,data}` 解包;分页统一读取 `records/page/size/total/pages`;响应中的 `int64` ID 在前端保持字符串。写请求中的企业、用户和成员 ID 也在类型适配边界保持数字字符串,避免 JavaScript 大整数精度损失,由服务端 Long DTO 解析。
|
||||
|
||||
@@ -4,13 +4,8 @@ import {
|
||||
cancelTask,
|
||||
changeTemplateStatus,
|
||||
createNotification,
|
||||
getInboxDetail,
|
||||
getUnreadCount,
|
||||
listInbox,
|
||||
listTasks,
|
||||
listTemplates,
|
||||
markAllInboxRead,
|
||||
markInboxRead,
|
||||
retryTask,
|
||||
updateTemplate,
|
||||
} from './notificationCenter'
|
||||
@@ -21,38 +16,12 @@ vi.mock('@/shared/api/http', () => ({
|
||||
}))
|
||||
|
||||
const envelope = <T>(data: T) => ({ code: '200', data })
|
||||
const inbox = { id: '9007199254740993', category: 'ORDER', title: '已发货', status: 'UNREAD' as const, sentAt: '2026-08-12T10:00:00Z', readAt: null }
|
||||
const template = { id: 12, templateCode: 'ORDER_SENT', templateName: '订单已发货', category: 'ORDER', channel: 'IN_APP' as const, titleTemplate: '已发货', bodyTemplate: '正文', variableWhitelist: [], status: 'DRAFT' as const, version: 1 }
|
||||
const task = { id: 13, taskNo: 'NT-13', category: 'ORDER', title: '已发货', status: 'CREATED' as const, recipientCount: 2, scheduledAt: null, sentAt: null, duplicated: false }
|
||||
|
||||
describe('notificationCenter API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('uses the platform inbox paths and normalizes envelope pagination and ids', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [inbox], page: '1', size: '20', total: '1', pages: '1' }))
|
||||
const result = await listInbox({ page: 1, size: 20, status: 'UNREAD' })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/notifications', { params: { page: 1, size: 20, status: 'UNREAD' } })
|
||||
expect(result).toMatchObject({ page: 1, size: 20, total: 1, pages: 1 })
|
||||
expect(result.records[0]?.id).toBe(String(inbox.id))
|
||||
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ unreadCount: '7' }))
|
||||
expect(await getUnreadCount()).toBe(7)
|
||||
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/notifications/unread-count')
|
||||
})
|
||||
|
||||
it('gets and marks inbox messages without a request body', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ ...inbox, body: '正文', sourceType: null, sourceId: null }))
|
||||
vi.mocked(http.put)
|
||||
.mockResolvedValueOnce(envelope({ ...inbox, body: '正文', sourceType: null, sourceId: null }))
|
||||
.mockResolvedValueOnce(envelope({ updatedCount: '3' }))
|
||||
await getInboxDetail('10/20')
|
||||
await markInboxRead('10/20')
|
||||
expect(await markAllInboxRead()).toBe(3)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/notifications/10%2F20')
|
||||
expect(http.put).toHaveBeenNthCalledWith(1, '/platform/v1/notifications/10%2F20/read')
|
||||
expect(http.put).toHaveBeenNthCalledWith(2, '/platform/v1/notifications/read-all')
|
||||
})
|
||||
|
||||
it('posts the frozen create-notification DTO unchanged', async () => {
|
||||
vi.mocked(http.post).mockResolvedValueOnce(envelope(task))
|
||||
const payload = { tenantId: '0', templateCode: 'ORDER_SENT', recipientType: 'PLATFORM_ROLE' as const, roleCode: 'PLATFORM_ADMIN', variables: {}, scheduledAt: null, idempotencyKey: 'notification-1' }
|
||||
|
||||
@@ -1,34 +1,20 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { CreateNotificationRequest, CreateTemplateRequest, InboxDetail, InboxItem, InboxStatus, NotificationTask, NotificationTaskStatus, NotificationTemplate, PageResult, TemplateStatus, UpdateTemplateRequest } from '../types'
|
||||
import type { CreateNotificationRequest, CreateTemplateRequest, NotificationTask, NotificationTaskStatus, NotificationTemplate, PageResult, TemplateStatus, UpdateTemplateRequest } from '../types'
|
||||
|
||||
type RawId = string | number
|
||||
type RawPage<T> = Omit<PageResult<T>, 'page' | 'size' | 'total' | 'pages'> & { page: RawId; size: RawId; total: RawId; pages: RawId }
|
||||
type RawInbox = Omit<InboxItem, 'id'> & { id: RawId }
|
||||
type RawInboxDetail = Omit<InboxDetail, 'id'> & { id: RawId }
|
||||
type RawTemplate = Omit<NotificationTemplate, 'id'> & { id: RawId }
|
||||
type RawTask = Omit<NotificationTask, 'id'> & { id: RawId }
|
||||
|
||||
const inboxPath = '/platform/v1/notifications'
|
||||
const notificationPath = '/platform/v1/notifications'
|
||||
const templatePath = '/platform/v1/notification-templates'
|
||||
const taskPath = '/platform/v1/notification-tasks'
|
||||
const id = (value: RawId) => String(value)
|
||||
const page = <T>(value: RawPage<T>): PageResult<T> => ({ ...value, page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) })
|
||||
const inbox = (value: RawInbox): InboxItem => ({ ...value, id: id(value.id) })
|
||||
const inboxDetail = (value: RawInboxDetail): InboxDetail => ({ ...value, id: id(value.id) })
|
||||
const template = (value: RawTemplate): NotificationTemplate => ({ ...value, id: id(value.id) })
|
||||
const task = (value: RawTask): NotificationTask => ({ ...value, id: id(value.id) })
|
||||
|
||||
export async function listInbox(query: { page: number; size: number; status?: InboxStatus; category?: string }) {
|
||||
const response = await http.get<ApiEnvelope<RawPage<RawInbox>>, ApiEnvelope<RawPage<RawInbox>>>(inboxPath, { params: query })
|
||||
const data = unwrapApiResponse(response)
|
||||
return page({ ...data, records: data.records.map(inbox) })
|
||||
}
|
||||
export async function getUnreadCount() { const response = await http.get<ApiEnvelope<{ unreadCount: RawId }>, ApiEnvelope<{ unreadCount: RawId }>>(`${inboxPath}/unread-count`); return Number(unwrapApiResponse(response).unreadCount) }
|
||||
export async function getInboxDetail(messageId: string) { const response = await http.get<ApiEnvelope<RawInboxDetail>, ApiEnvelope<RawInboxDetail>>(`${inboxPath}/${encodeURIComponent(messageId)}`); return inboxDetail(unwrapApiResponse(response)) }
|
||||
export async function markInboxRead(messageId: string) { const response = await http.put<ApiEnvelope<RawInboxDetail>, ApiEnvelope<RawInboxDetail>>(`${inboxPath}/${encodeURIComponent(messageId)}/read`); return inboxDetail(unwrapApiResponse(response)) }
|
||||
export async function markAllInboxRead() { const response = await http.put<ApiEnvelope<{ updatedCount: RawId }>, ApiEnvelope<{ updatedCount: RawId }>>(`${inboxPath}/read-all`); return Number(unwrapApiResponse(response).updatedCount) }
|
||||
|
||||
export async function createNotification(payload: CreateNotificationRequest) { const response = await http.post<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(inboxPath, payload); return task(unwrapApiResponse(response)) }
|
||||
export async function createNotification(payload: CreateNotificationRequest) { const response = await http.post<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(notificationPath, payload); return task(unwrapApiResponse(response)) }
|
||||
export async function listTemplates(query: { page: number; size: number; templateCode?: string; category?: string; title?: string; status?: TemplateStatus }) { const response = await http.get<ApiEnvelope<RawPage<RawTemplate>>, ApiEnvelope<RawPage<RawTemplate>>>(templatePath, { params: query }); const data = unwrapApiResponse(response); return page({ ...data, records: data.records.map(template) }) }
|
||||
export async function createTemplate(payload: CreateTemplateRequest) { const response = await http.post<ApiEnvelope<RawTemplate>, ApiEnvelope<RawTemplate>>(templatePath, payload); return template(unwrapApiResponse(response)) }
|
||||
export async function updateTemplate(templateId: string, payload: UpdateTemplateRequest) { const response = await http.put<ApiEnvelope<RawTemplate>, ApiEnvelope<RawTemplate>>(`${templatePath}/${encodeURIComponent(templateId)}`, payload); return template(unwrapApiResponse(response)) }
|
||||
|
||||
@@ -27,14 +27,12 @@ describe('notification center contract UI', () => {
|
||||
it.each([
|
||||
'./pages/MessageManagementView.vue',
|
||||
'./pages/MessageTemplatesView.vue',
|
||||
'./pages/MyMessagesView.vue',
|
||||
])('keeps actions as the first table column in %s', (path) => {
|
||||
const content = source(path)
|
||||
expect(content).toMatch(/const columns:[^=]+\s*=\s*\[\{key:'actions',label:'操作'/)
|
||||
})
|
||||
|
||||
it('does not expose unsupported clear-read or disabled-template restore actions', () => {
|
||||
expect(source('./pages/MyMessagesView.vue')).not.toContain('清空已读')
|
||||
const templates = source('./pages/MessageTemplatesView.vue')
|
||||
expect(templates).toContain("status!=='DISABLED'")
|
||||
expect(templates).toContain("status==='DRAFT'?'激活':'禁用'")
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/notifications/pages/MyMessagesView.vue'), 'utf8')
|
||||
|
||||
describe('MyMessagesView permissions', () => {
|
||||
it('keeps inbox viewing separate from read operations', () => {
|
||||
expect(source).toContain("access.can('platform:notification:read')")
|
||||
expect(source).toContain("access.can('platform:notification:read-all')")
|
||||
expect(source).toContain("detail.value.status==='UNREAD'&&canMarkRead.value")
|
||||
expect(source).toContain('v-if="canMarkAllRead"')
|
||||
expect(source).toContain("v-if=\"canMarkRead&&(row as InboxItem).status==='UNREAD'\"")
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'; import { CheckCheck, Eye } from '@lucide/vue'; import { ElMessage } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'; import AppDialog from '@/shared/components/AppDialog.vue'; import DataTable,{type DataTableColumn} from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import QueryToolbar,{type QueryField,type QueryModel} from '@/shared/components/QueryToolbar.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import { getInboxDetail,listInbox,markAllInboxRead,markInboxRead } from '../api/notificationCenter'; import type { InboxDetail,InboxItem,InboxStatus } from '../types'; import { getApiErrorDetails,getApiErrorMessage,type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
const access=useAccessPreviewStore(),canMarkRead=computed(()=>access.can('platform:notification:read')),canMarkAllRead=computed(()=>access.can('platform:notification:read-all'))
|
||||
const page=ref(1),pageSize=ref(20),loading=ref(false),actionId=ref(''),error=ref<ApiErrorDetails|null>(null),detailOpen=ref(false),detailLoading=ref(false),detail=ref<InboxDetail|null>(null),result=ref({records:[] as InboxItem[],total:0,pages:0}),filters=ref<QueryModel>({status:'',category:''})
|
||||
const queryFields:QueryField[]=[{key:'status',type:'select',label:'消息状态',placeholder:'全部状态',options:[{label:'未读',value:'UNREAD'},{label:'已读',value:'READ'}]},{key:'category',type:'input',label:'业务分类',placeholder:'业务分类'}]
|
||||
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:210,fixed:'left',slotName:'actions'},{key:'title',prop:'title',label:'消息标题',minWidth:240},{key:'category',prop:'category',label:'业务分类',width:140},{key:'sentAt',label:'发送时间',width:170,slotName:'sentAt'},{key:'status',label:'状态',width:100,slotName:'status'}]
|
||||
function formatDate(v:string|null){return v?new Date(v).toLocaleString('zh-CN',{hour12:false}):'—'}
|
||||
async function load(){loading.value=true;error.value=null;try{const d=await listInbox({page:page.value,size:pageSize.value,status:(filters.value.status||undefined) as InboxStatus|undefined,category:String(filters.value.category||'').trim()||undefined});result.value={records:d.records,total:d.total,pages:d.pages}}catch(e){error.value=getApiErrorDetails(e,'我的消息加载失败')}finally{loading.value=false}}
|
||||
function search(){page.value===1?void load():page.value=1}
|
||||
async function openDetail(row:InboxItem){detailOpen.value=true;detailLoading.value=true;detail.value=null;try{detail.value=await getInboxDetail(row.id);if(detail.value.status==='UNREAD'&&canMarkRead.value){detail.value=await markInboxRead(row.id);await load();window.dispatchEvent(new CustomEvent('cc-notification-unread-changed'))}}catch(e){ElMessage.error(getApiErrorMessage(e,'消息详情加载失败'))}finally{detailLoading.value=false}}
|
||||
async function markRead(row:InboxItem){actionId.value=row.id;try{await markInboxRead(row.id);ElMessage.success('已标为已读');await load();window.dispatchEvent(new CustomEvent('cc-notification-unread-changed'))}catch(e){ElMessage.error(getApiErrorMessage(e,'标记已读失败'))}finally{actionId.value=''}}
|
||||
async function markAll(){try{const count=await markAllInboxRead();ElMessage.success(count?`已将 ${count} 条消息标为已读`:'没有需要更新的未读消息');await load();window.dispatchEvent(new CustomEvent('cc-notification-unread-changed'))}catch(e){ElMessage.error(getApiErrorMessage(e,'全部标为已读失败'))}}
|
||||
watch([page,pageSize],load);onMounted(load)
|
||||
</script>
|
||||
<template><AdminPage><ListPageLayout aria-label="我的消息列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="我的消息筛选" :fields="queryFields" preference-key="notifications-mine-query" @search="search"/></template><template v-if="error" #state><LoadErrorState title="我的消息加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error"/></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-mine-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息"><template #toolbar-actions><el-button v-if="canMarkAllRead" @click="markAll"><CheckCheck :size="16"/>全部标为已读</el-button></template><template #pagination-left><span>共 {{result.total}} 条消息</span></template><template #actions="{row}"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as InboxItem)"><Eye :size="15"/>查看</el-button><el-button v-if="canMarkRead&&(row as InboxItem).status==='UNREAD'" link type="primary" :loading="actionId===(row as InboxItem).id" @click="markRead(row as InboxItem)"><CheckCheck :size="15"/>标为已读</el-button></div></template><template #sentAt="{row}">{{formatDate((row as InboxItem).sentAt)}}</template><template #status="{row}"><el-tag :type="(row as InboxItem).status==='UNREAD'?'danger':'info'">{{(row as InboxItem).status==='UNREAD'?'未读':'已读'}}</el-tag></template></DataTable></section></ListPageLayout><AppDialog v-model="detailOpen" title="消息详情" width="min(680px, calc(100vw - 28px))" :loading="detailLoading" :show-confirm="false"><article v-if="detail" class="message-detail"><header><div><h3>{{detail.title}}</h3><p>{{detail.category}} · {{formatDate(detail.sentAt)}}</p></div><el-tag :type="detail.status==='UNREAD'?'danger':'info'">{{detail.status==='UNREAD'?'未读':'已读'}}</el-tag></header><div class="message-body">{{detail.body}}</div><dl v-if="detail.sourceType||detail.sourceId"><div><dt>来源类型</dt><dd>{{detail.sourceType||'—'}}</dd></div><div><dt>来源标识</dt><dd>{{detail.sourceId||'—'}}</dd></div></dl></article></AppDialog></AdminPage></template>
|
||||
<style scoped>.table-actions{display:flex;min-height:32px;align-items:center;gap:8px}.table-actions :deep(.el-button+.el-button){margin-left:0}.message-detail header{display:flex;justify-content:space-between;gap:16px;padding-bottom:16px;border-bottom:1px solid var(--cc-border)}.message-detail h3{margin:0;font-size:17px}.message-detail p{margin:6px 0 0;color:var(--cc-muted);font-size:12px}.message-body{padding:20px 0;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.8}.message-detail dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0;border-top:1px solid var(--cc-border)}.message-detail dl>div{padding:12px}.message-detail dt{color:var(--cc-muted);font-size:12px}.message-detail dd{margin:5px 0 0;overflow-wrap:anywhere}@media(max-width:480px){.message-detail header{flex-direction:column}.message-detail dl{grid-template-columns:1fr}}</style>
|
||||
@@ -1,11 +1,8 @@
|
||||
export type InboxStatus = 'UNREAD' | 'READ'
|
||||
export type TemplateStatus = 'DRAFT' | 'ACTIVE' | 'DISABLED'
|
||||
export type NotificationTaskStatus = 'CREATED' | 'SCHEDULED' | 'DISPATCHING' | 'RETRYING' | 'SENT' | 'DEAD' | 'CANCELLED'
|
||||
export type RecipientType = 'USER' | 'MEMBERSHIP' | 'TENANT_ROLE' | 'TENANT_ALL' | 'PLATFORM_ROLE'
|
||||
|
||||
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||
export interface InboxItem { id: string; category: string; title: string; status: InboxStatus; sentAt: string; readAt: string | null }
|
||||
export interface InboxDetail extends InboxItem { body: string; sourceType: string | null; sourceId: string | null }
|
||||
export interface NotificationTemplate { id: string; templateCode: string; templateName: string; category: string; channel: 'IN_APP'; titleTemplate: string; bodyTemplate: string; variableWhitelist: string[]; status: TemplateStatus; version: number }
|
||||
export interface NotificationTask { id: string; taskNo: string; category: string; title: string; status: NotificationTaskStatus; recipientCount: number; scheduledAt: string | null; sentAt: string | null; duplicated: boolean }
|
||||
|
||||
|
||||
@@ -117,13 +117,13 @@ describe('PalletsView', () => {
|
||||
expect(palletsSource).toContain('stripe')
|
||||
expect(palletsSource).toContain('size="small" plain type="primary"')
|
||||
expect(palletsSource).toMatch(/size="small"\s+plain\s+type="danger"/)
|
||||
expect(palletsSource).toContain('<Eye :size="15" />')
|
||||
expect(palletsSource).not.toContain('<Eye :size="15" />')
|
||||
expect(palletsSource).toContain('<Pencil :size="15" />')
|
||||
expect(palletsSource).toContain('<Trash2 :size="15" />')
|
||||
expect(palletsSource).toContain('@media (max-width: 720px)')
|
||||
})
|
||||
it('uses the prototype-sized centered editor while retaining the detail drawer', () => {
|
||||
expect(palletsSource).toContain('<AppDrawer v-model="detailOpen"')
|
||||
it('uses the prototype-sized centered editor without an extra detail drawer', () => {
|
||||
expect(palletsSource).not.toContain('<AppDrawer')
|
||||
expect(palletsSource).toContain('<AppDialog v-model="editorOpen" :title="editorTitle" width="min(560px, calc(100vw - 28px))"')
|
||||
expect(palletsSource).not.toContain('<el-drawer v-model="editorOpen"')
|
||||
expect(palletsSource).toContain(':global(.pallet-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }')
|
||||
@@ -243,7 +243,6 @@ describe('PalletsView', () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
const bodyRows = wrapper.findAll('.el-table__body-wrapper tbody tr')
|
||||
expect(bodyRows[0]?.text()).toContain('查看')
|
||||
expect(bodyRows[0]?.text()).not.toContain('编辑')
|
||||
expect(bodyRows[1]?.text()).toContain('编辑')
|
||||
})
|
||||
@@ -277,7 +276,7 @@ describe('PalletsView', () => {
|
||||
inputNumbers[0]?.vm.$emit('update:modelValue', 1.5)
|
||||
inputNumbers[1]?.vm.$emit('update:modelValue', 10)
|
||||
await wrapper.find('input[placeholder="例如 黄色"]').setValue('黑色')
|
||||
await wrapper.find('textarea[placeholder="可填写其他说明"]').setValue('标准托盘')
|
||||
await wrapper.find('input[placeholder="选填"]').setValue('标准托盘')
|
||||
const saveButton = wrapper.findAll('button').find((button) => button.text().trim() === '保存托盘')
|
||||
await saveButton?.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -287,7 +286,7 @@ describe('PalletsView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('groups the pallet editor into clear ownership and specification sections', async () => {
|
||||
it('uses a compact three-row two-column editor', async () => {
|
||||
const wrapper = await mountView()
|
||||
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增托盘')
|
||||
await createButton?.trigger('click')
|
||||
@@ -296,9 +295,9 @@ describe('PalletsView', () => {
|
||||
const editorDialog = document.body.querySelector('.pallet-editor-dialog')
|
||||
expect(editorDialog?.textContent).toContain('新增托盘')
|
||||
expect(editorDialog?.querySelector('.el-dialog__title')?.textContent).toBe('新增托盘')
|
||||
expect(document.body.querySelectorAll('.editor-section')).toHaveLength(2)
|
||||
expect(document.body.querySelector('#pallet-ownership-heading')?.textContent).toBe('归属仓库')
|
||||
expect(document.body.querySelector('#pallet-spec-heading')?.textContent).toBe('托盘规格')
|
||||
expect(document.body.querySelectorAll('.editor-field-grid')).toHaveLength(3)
|
||||
expect(document.body.querySelector('.editor-section')).toBeNull()
|
||||
expect(document.body.querySelector('.editor-context')).toBeNull()
|
||||
expect(document.body.querySelector('.pallet-editor-footer')?.textContent).toContain('为必填项')
|
||||
})
|
||||
|
||||
@@ -309,7 +308,7 @@ describe('PalletsView', () => {
|
||||
const actions = wrapper.find('.pallet-row-actions').text()
|
||||
expect(actions).not.toContain('编辑')
|
||||
expect(actions).not.toContain('删除')
|
||||
expect(actions).toContain('查看')
|
||||
expect(actions).not.toContain('查看')
|
||||
})
|
||||
|
||||
it('shows a recoverable list failure instead of demo data', async () => {
|
||||
@@ -333,59 +332,22 @@ describe('PalletsView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows only the pallet fields exposed by the backend contract', async () => {
|
||||
it('keeps the list limited to fields exposed by the backend contract', async () => {
|
||||
const publicPallet = { ...pallet, tenantId: '0' }
|
||||
vi.mocked(listPlatformPallets).mockResolvedValue({ records: [publicPallet], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformPallet).mockResolvedValue(publicPallet)
|
||||
const wrapper = await mountView()
|
||||
await loadRows(wrapper)
|
||||
expect(wrapper.findComponent(DataTable).props('data')).toEqual([publicPallet])
|
||||
const tableBody = wrapper.find('.el-table__body-wrapper')
|
||||
const viewButton = tableBody.findAll('.pallet-row-actions button').find((button) => button.text().trim() === '查看')
|
||||
await viewButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getPlatformPallet).toHaveBeenCalledWith(19001, '20001')
|
||||
expect(document.body.textContent).toContain('资源作用域')
|
||||
expect(document.body.textContent).toContain('平台公共资源')
|
||||
expect(document.body.textContent).not.toContain('所属企业')
|
||||
expect(document.body.textContent).toContain('托盘编码')
|
||||
expect(document.body.textContent).toContain('托盘类型')
|
||||
expect(document.body.textContent).toContain('托盘容积')
|
||||
expect(document.body.textContent).toContain('归属信息')
|
||||
expect(document.body.textContent).toContain('托盘规格')
|
||||
expect(document.body.textContent).toContain('记录信息')
|
||||
expect(document.body.textContent).not.toContain('企业 ID')
|
||||
expect(document.body.textContent).not.toContain('租户')
|
||||
expect(document.body.textContent).not.toContain('托盘状态')
|
||||
expect(document.body.textContent).not.toContain('最大承重')
|
||||
})
|
||||
|
||||
it('keeps the latest detail when an older pallet request finishes last', async () => {
|
||||
const secondPallet = { ...pallet, id: '20002', palletCode: 'TP-002' }
|
||||
let resolveFirst!: (value: PlatformPalletResource) => void
|
||||
let resolveSecond!: (value: PlatformPalletResource) => void
|
||||
vi.mocked(getPlatformPallet).mockImplementation((_warehouseId, palletId) => new Promise((resolve) => {
|
||||
if (String(palletId) === String(pallet.id)) resolveFirst = resolve
|
||||
else resolveSecond = resolve
|
||||
}))
|
||||
const wrapper = await mountView()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
detailOpen: boolean
|
||||
detail: PlatformPalletResource | null
|
||||
openDetail(row: PlatformPalletResource): Promise<void>
|
||||
}
|
||||
|
||||
void vm.openDetail(pallet)
|
||||
vm.detailOpen = false
|
||||
await wrapper.vm.$nextTick()
|
||||
void vm.openDetail(secondPallet)
|
||||
resolveSecond(secondPallet)
|
||||
await flushPromises()
|
||||
resolveFirst(pallet)
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.detail?.palletCode).toBe('TP-002')
|
||||
const tableText = wrapper.find('.data-table').text()
|
||||
expect(tableText).toContain('托盘编码')
|
||||
expect(tableText).toContain('托盘类型')
|
||||
expect(tableText).toContain('托盘容积')
|
||||
expect(tableText).not.toContain('所属企业')
|
||||
expect(tableText).not.toContain('企业 ID')
|
||||
expect(tableText).not.toContain('租户')
|
||||
expect(tableText).not.toContain('托盘状态')
|
||||
expect(tableText).not.toContain('最大承重')
|
||||
})
|
||||
|
||||
it('keeps the latest pallet selected for editing', async () => {
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Boxes, Eye, Pencil, Plus, Save, Trash2, Warehouse as WarehouseIcon } from '@lucide/vue'
|
||||
import { Boxes, Pencil, Plus, Save, Trash2, Warehouse as WarehouseIcon } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.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 FormSection from '@/shared/components/FormSection.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
@@ -35,15 +33,11 @@ const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const editorOpen = ref(false)
|
||||
const detailOpen = ref(false)
|
||||
const detail = ref<PlatformPalletResource | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const deletingIds = ref(new Set<string>())
|
||||
const formRef = ref<FormInstance>()
|
||||
let warehouseSequence = 0
|
||||
let palletSequence = 0
|
||||
let detailSequence = 0
|
||||
let editorSequence = 0
|
||||
|
||||
type PalletFilters = QueryModel & { warehouseId: string; palletCode: string; palletType: string }
|
||||
@@ -115,7 +109,7 @@ const domesticWarehouses = computed(() => warehouses.value.filter((warehouse) =>
|
||||
)))
|
||||
const editorTitle = computed(() => form.id ? '编辑托盘' : '新增托盘')
|
||||
const editorPermission = computed(() => form.id ? 'platform:pallet:edit' : 'platform:pallet:create')
|
||||
const contextLocked = computed(() => editorOpen.value || detailOpen.value || saving.value || deletingIds.value.size > 0)
|
||||
const contextLocked = computed(() => editorOpen.value || saving.value || deletingIds.value.size > 0)
|
||||
const filtered = computed(() => Object.values(appliedFilters.value).some((value) => String(value || '').trim()))
|
||||
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
@@ -127,7 +121,7 @@ const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
})
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', minWidth: 224, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', minWidth: 160, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'pallet', label: '托盘编码', minWidth: 190, slotName: 'pallet' },
|
||||
{ key: 'warehouse', label: '所属仓库', minWidth: 210, slotName: 'warehouse' },
|
||||
{ key: 'palletType', prop: 'palletType', label: '托盘类型', minWidth: 140 },
|
||||
@@ -142,10 +136,6 @@ function apiId(value: string) {
|
||||
return Number.isSafeInteger(numeric) ? numeric : value
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
return value?.replace('T', ' ').slice(0, 16) || '—'
|
||||
}
|
||||
|
||||
function formatCapacity(value: number | string) {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return String(value)
|
||||
@@ -262,24 +252,6 @@ async function fetchDetail(row: PlatformPalletResource) {
|
||||
return getPlatformPallet(apiId(String(row.warehouseId)), row.id)
|
||||
}
|
||||
|
||||
async function openDetail(row: PlatformPalletResource) {
|
||||
const sequence = ++detailSequence
|
||||
detailLoading.value = true
|
||||
detailOpen.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
const result = await fetchDetail(row)
|
||||
if (sequence !== detailSequence || !detailOpen.value) return
|
||||
detail.value = result
|
||||
} catch (error) {
|
||||
if (sequence !== detailSequence) return
|
||||
detailOpen.value = false
|
||||
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘详情加载失败,请稍后重试')))
|
||||
} finally {
|
||||
if (sequence === detailSequence) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openEdit(row: PlatformPalletResource) {
|
||||
if (!isDomesticWarehouse(row.warehouseId)) {
|
||||
ElMessage.warning('仅支持编辑国内仓托盘')
|
||||
@@ -358,12 +330,6 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
if (nextPage === previousPage && nextSize === previousSize) return
|
||||
void loadPallets()
|
||||
})
|
||||
watch(detailOpen, (open) => {
|
||||
if (open) return
|
||||
detailSequence += 1
|
||||
detailLoading.value = false
|
||||
detail.value = null
|
||||
})
|
||||
watch(() => access.can(editorPermission.value), (canWrite) => {
|
||||
if (!canWrite && editorOpen.value) editorOpen.value = false
|
||||
}, { flush: 'sync' })
|
||||
@@ -423,7 +389,6 @@ onMounted(() => {
|
||||
<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 size="small" plain type="primary" title="查看托盘" @click="openDetail(row as PlatformPalletResource)"><Eye :size="15" />查看</el-button>
|
||||
<el-button v-if="access.can('platform:pallet:edit') && isDomesticWarehouse((row as PlatformPalletResource).warehouseId)" size="small" plain type="primary" title="编辑托盘" @click="openEdit(row as PlatformPalletResource)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button
|
||||
v-if="access.can('platform:pallet:delete')"
|
||||
@@ -450,42 +415,10 @@ onMounted(() => {
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDrawer v-model="detailOpen" title="托盘详情" size="min(520px, 94vw)" :loading="detailLoading" :show-confirm="false">
|
||||
<div class="detail-body">
|
||||
<template v-if="detail">
|
||||
<div class="detail-hero"><span><Boxes :size="22" /></span><div><h2>{{ detail.palletCode }}</h2><p>{{ detail.palletType }}</p></div></div>
|
||||
<h3 class="detail-section-title">归属信息</h3>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="资源作用域">平台公共资源</el-descriptions-item>
|
||||
<el-descriptions-item label="所属仓库">{{ warehouseFor(detail.warehouseId)?.warehouseName || '—' }}({{ warehouseFor(detail.warehouseId)?.warehouseCode || '—' }})</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h3 class="detail-section-title">托盘规格</h3>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="托盘编码">{{ detail.palletCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="托盘类型">{{ detail.palletType }}</el-descriptions-item>
|
||||
<el-descriptions-item label="托盘容积">{{ formatCapacity(detail.capacityCbm) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="托盘重量">{{ formatWeight(detail.weightKg) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="颜色">{{ detail.color || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h3 class="detail-section-title">记录信息</h3>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(detail.createdAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDateTime(detail.updatedAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
|
||||
</AppDrawer>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorTitle" width="min(560px, calc(100vw - 28px))" class="pallet-editor-dialog" :close-on-click-modal="false" :confirm-loading="saving" :show-confirm="false" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="editor-form">
|
||||
<FormSection class="editor-section" title="归属仓库" description="托盘创建后固定归属于所选仓库,不支持跨仓移动。" aria-labelledby="pallet-ownership-heading">
|
||||
<span id="pallet-ownership-heading" class="sr-only">归属仓库</span>
|
||||
<div v-if="form.id" class="editor-context">
|
||||
<div><strong>{{ editorWarehouse?.warehouseName || '当前仓库' }}</strong><span>{{ editorWarehouse?.warehouseCode || form.warehouseId }} · 平台公共资源</span></div>
|
||||
</div>
|
||||
<el-form-item v-else label="所属仓库" prop="warehouseId" required>
|
||||
<div class="editor-field-grid">
|
||||
<el-form-item label="所属仓库" prop="warehouseId" required>
|
||||
<el-select v-model="form.warehouseId" filterable placeholder="请选择国内仓" :loading="warehouseLoading">
|
||||
<el-option
|
||||
v-for="warehouse in domesticWarehouses"
|
||||
@@ -495,15 +428,7 @@ onMounted(() => {
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</FormSection>
|
||||
|
||||
<FormSection class="editor-section" title="托盘规格" description="编码由系统自动生成,请维护类型、容积、重量和颜色。" aria-labelledby="pallet-spec-heading">
|
||||
<span id="pallet-spec-heading" class="sr-only">托盘规格</span>
|
||||
<div class="editor-field-grid">
|
||||
<el-form-item label="托盘编码">
|
||||
<el-input :model-value="form.palletCode || '保存后自动生成'" disabled aria-label="托盘编码(系统生成)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="托盘类型" prop="palletType" required>
|
||||
<el-form-item label="托盘类型" prop="palletType" required>
|
||||
<el-select
|
||||
v-model="form.palletType"
|
||||
filterable
|
||||
@@ -514,23 +439,24 @@ onMounted(() => {
|
||||
>
|
||||
<el-option v-for="type in palletTypeOptions" :key="type" :label="type" :value="type" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="editor-field-grid">
|
||||
<el-form-item label="托盘容积(m³)" prop="capacityCbm" required>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="editor-field-grid">
|
||||
<el-form-item label="托盘容积(m³)" prop="capacityCbm" required>
|
||||
<el-input-number v-model="form.capacityCbm" :min="0.000001" :max="999999999999.999999" :precision="6" :step="0.1" controls-position="right" aria-label="托盘容积" />
|
||||
</el-form-item>
|
||||
<el-form-item label="托盘重量(kg)" prop="weightKg" required>
|
||||
</el-form-item>
|
||||
<el-form-item label="托盘重量(kg)" prop="weightKg" required>
|
||||
<el-input-number v-model="form.weightKg" :min="0.001" :max="999999999999999.999" :precision="3" :step="0.5" controls-position="right" aria-label="托盘重量" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="editor-field-grid">
|
||||
<el-form-item label="颜色" prop="color" required>
|
||||
<el-input v-model="form.color" maxlength="32" show-word-limit placeholder="例如 黄色" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark" class="editor-remark-field">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="可填写其他说明" />
|
||||
<el-input v-model="form.remark" maxlength="500" show-word-limit placeholder="选填" />
|
||||
</el-form-item>
|
||||
</FormSection>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="drawer-footer pallet-editor-footer">
|
||||
@@ -553,32 +479,14 @@ onMounted(() => {
|
||||
.stacked-value { display: grid; min-width: 0; gap: 3px; }
|
||||
.stacked-value strong, .stacked-value small { overflow-wrap: anywhere; }
|
||||
.stacked-value small { color: var(--cc-muted); font-size: 11px; }
|
||||
.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 { 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; 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; }
|
||||
.detail-hero h2 { font-size: 18px; }
|
||||
.detail-hero p { margin-top: 4px; color: var(--cc-muted); font-size: 13px; }
|
||||
.detail-section-title { margin: 20px 0 8px; color: var(--cc-text); font-size: 14px; }
|
||||
.detail-section-title:first-of-type { margin-top: 0; }
|
||||
:global(.pallet-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
|
||||
:global(.pallet-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
|
||||
:global(.pallet-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
||||
.editor-form { padding: 4px 24px 28px; }
|
||||
.editor-section { padding: 20px 0 4px; border-bottom: 1px solid var(--cc-border); }
|
||||
.editor-section:last-child { padding-bottom: 0; border-bottom: 0; }
|
||||
.editor-section-heading { display: flex; align-items: flex-start; gap: 9px; margin-bottom: 18px; }
|
||||
.editor-section-heading > svg { margin-top: 1px; flex: 0 0 auto; color: var(--cc-primary); }
|
||||
.editor-section-heading h3 { margin: 0; color: #172033; font-size: 14px; line-height: 1.4; }
|
||||
.editor-section-heading p { margin: 3px 0 0; color: var(--cc-muted); font-size: 11px; line-height: 1.5; }
|
||||
.editor-form { padding: 8px 24px 12px; }
|
||||
.editor-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
|
||||
.editor-context { margin-bottom: 18px; padding: 12px 14px; border-radius: 6px; background: #f8fafc; }
|
||||
.editor-context div { display: grid; gap: 3px; }
|
||||
.editor-context strong { color: #172033; font-size: 13px; }
|
||||
.editor-context span { color: var(--cc-muted); font-size: 11px; }
|
||||
.editor-form :deep(.el-input-number), .editor-form :deep(.el-select) { width: 100%; }
|
||||
.editor-form :deep(.el-form-item) { margin-bottom: 18px; }
|
||||
.editor-form :deep(.el-form-item__label) { padding-bottom: 7px; color: #344054; font-size: 12px; font-weight: 650; line-height: 1.4; }
|
||||
@@ -596,7 +504,6 @@ onMounted(() => {
|
||||
.pallet-editor-footer > div :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.detail-body { padding: 16px; }
|
||||
.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; }
|
||||
|
||||
@@ -122,7 +122,6 @@ export const prototypeModuleRoutes: RouteRecordRaw[] = [
|
||||
...createRoutes(moduleViews.messages, [
|
||||
['prototype/messages/management', 'message-management', '消息管理'],
|
||||
['prototype/messages/templates', 'message-template', '模板管理'],
|
||||
['prototype/messages/my-messages', 'my-messages', '我的消息'],
|
||||
]),
|
||||
...createRoutes(moduleViews.systemManagement, [
|
||||
['prototype/system-management/tenant-management', 'tenant-management', '租户管理'],
|
||||
|
||||
Reference in New Issue
Block a user