This commit is contained in:
2026-08-05 17:10:10 +08:00
parent 4b31eb7786
commit 6736893128
4 changed files with 129 additions and 55 deletions
@@ -200,7 +200,12 @@ describe('LocationsView', () => {
const wrapper = await mountView() const wrapper = await mountView()
await selectScope(wrapper) await selectScope(wrapper)
expect(wrapper.find('.table-panel .panel-heading').exists()).toBe(false) 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('新增库位')
expect(wrapper.findComponent(QueryToolbar).text()).not.toContain('新增库位')
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200) expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
expect(listPlatformLocations).toHaveBeenLastCalledWith({ expect(listPlatformLocations).toHaveBeenLastCalledWith({
page: 1, page: 1,
@@ -220,7 +225,7 @@ describe('LocationsView', () => {
it('opens creation without a list filter and requires the warehouse inside the form', async () => { it('opens creation without a list filter and requires the warehouse inside the form', async () => {
const wrapper = await mountView() const wrapper = await mountView()
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增库位') const createButton = wrapper.find('.table-panel .panel-heading').findAll('button').find((button) => button.text().trim() === '新增库位')
expect(createButton?.attributes('disabled')).toBeUndefined() expect(createButton?.attributes('disabled')).toBeUndefined()
await createButton!.trigger('click') await createButton!.trigger('click')
@@ -495,15 +495,7 @@ onMounted(() => {
ariaLabel="库位筛选" ariaLabel="库位筛选"
:fields="queryFields" :fields="queryFields"
@search="applyFilters" @search="applyFilters"
> />
<template #actions>
<el-button
v-if="access.can('platform:location:create')"
type="primary"
@click="openCreate"
><Plus :size="16" />新增库位</el-button>
</template>
</QueryToolbar>
<el-alert v-if="customerError" type="warning" :closable="false" show-icon class="state-alert"> <el-alert v-if="customerError" type="warning" :closable="false" show-icon class="state-alert">
<template #title>所属企业列表加载失败</template> <template #title>所属企业列表加载失败</template>
@@ -527,7 +519,15 @@ onMounted(() => {
<el-button size="small" @click="loadLocations()">重新查询</el-button> <el-button size="small" @click="loadLocations()">重新查询</el-button>
</el-alert> </el-alert>
<section class="surface-panel table-panel"> <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 <DataTable
v-model:page="page" v-model:page="page"
v-model:page-size="pageSize" v-model:page-size="pageSize"
@@ -239,6 +239,24 @@ describe('PalletsView', () => {
}) })
}) })
it('groups the pallet editor into clear ownership and specification sections', async () => {
const wrapper = await mountView()
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增托盘')
await createButton?.trigger('click')
await flushPromises()
expect(document.body.querySelector('.pallet-editor-header')?.textContent).toContain('新增托盘')
expect(document.body.querySelector('.pallet-editor-header')?.textContent).toContain('录入归属仓库与基础规格')
const editorDialog = document.body.querySelector('.pallet-editor-drawer[role="dialog"]')
const labelledBy = editorDialog?.getAttribute('aria-labelledby')
expect(labelledBy).toBeTruthy()
expect(document.getElementById(labelledBy || '')?.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.querySelector('.pallet-editor-footer')?.textContent).toContain('为必填项')
})
it('hides all write actions without write permissions', async () => { it('hides all write actions without write permissions', async () => {
const wrapper = await mountView(['platform:pallet:list']) const wrapper = await mountView(['platform:pallet:list'])
@@ -450,47 +450,73 @@ onMounted(() => {
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template> <template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
</el-drawer> </el-drawer>
<el-drawer v-model="editorOpen" :title="editorTitle" size="520px" class="business-drawer" :close-on-click-modal="false"> <el-drawer v-model="editorOpen" size="560px" class="business-drawer pallet-editor-drawer" :close-on-click-modal="false">
<div v-if="form.id" class="editor-context"> <template #header="{ titleId, titleClass }">
<WarehouseIcon :size="18" /> <div class="pallet-editor-header">
<div><strong>{{ editorWarehouse?.warehouseName || '当前仓库' }}({{ editorWarehouse?.warehouseCode || form.warehouseId }})</strong><span>客户 {{ customerLabel(form.customerId) }} · 托盘保存后不可跨仓移动</span></div> <span aria-hidden="true"><Boxes :size="20" /></span>
</div> <div>
<h2 :id="titleId" :class="titleClass">{{ editorTitle }}</h2>
<p>{{ form.id ? '调整托盘规格与现场识别信息' : '录入归属仓库与基础规格,创建后即可用于仓储作业' }}</p>
</div>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="editor-form"> <el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="editor-form">
<el-form-item v-if="!form.id" label="所属仓库" prop="warehouseId" required> <section class="editor-section" aria-labelledby="pallet-ownership-heading">
<el-select v-model="form.warehouseId" filterable placeholder="请选择所属仓库" :loading="warehouseLoading"> <div class="editor-section-heading">
<el-option <WarehouseIcon :size="18" aria-hidden="true" />
v-for="warehouse in warehouses" <div><h3 id="pallet-ownership-heading">归属仓库</h3><p>托盘创建后固定归属于所选仓库,不支持跨仓移动。</p></div>
:key="String(warehouse.id)" </div>
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" <div v-if="form.id" class="editor-context">
:value="String(warehouse.id)" <div><strong>{{ editorWarehouse?.warehouseName || '当前仓库' }}</strong><span>{{ editorWarehouse?.warehouseCode || form.warehouseId }} · 客户 {{ customerLabel(form.customerId) }}</span></div>
/> </div>
</el-select> <el-form-item v-else label="所属仓库" prop="warehouseId" required>
</el-form-item> <el-select v-model="form.warehouseId" filterable placeholder="请选择所属仓库" :loading="warehouseLoading">
<el-form-item label="托盘编码" prop="palletCode" required> <el-option
<el-input v-model="form.palletCode" maxlength="50" show-word-limit placeholder="例如 TP-001" /> v-for="warehouse in warehouses"
</el-form-item> :key="String(warehouse.id)"
<el-form-item label="托盘类型" prop="palletType" required> :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`"
<el-select :value="String(warehouse.id)"
v-model="form.palletType" />
filterable </el-select>
allow-create </el-form-item>
default-first-option </section>
placeholder="请选择或输入托盘类型"
aria-label="编辑托盘类型" <section class="editor-section" aria-labelledby="pallet-spec-heading">
> <div class="editor-section-heading">
<el-option v-for="type in palletTypeOptions" :key="type" :label="type" :value="type" /> <Boxes :size="18" aria-hidden="true" />
</el-select> <div><h3 id="pallet-spec-heading">托盘规格</h3><p>完善编码、类型与实际可用容积。</p></div>
</el-form-item> </div>
<el-form-item label="托盘容积(m³)" prop="capacityCbm" required> <div class="editor-field-grid">
<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 label="托盘编码" prop="palletCode" required>
<p class="field-help">用于服务端按总体积计算所需托盘数量;请输入托盘实际可用容积。</p> <el-input v-model="form.palletCode" maxlength="50" show-word-limit placeholder="例如 TP-001" />
</el-form-item> </el-form-item>
<el-form-item label="备注" prop="remark"> <el-form-item label="托盘类型" prop="palletType" required>
<el-input v-model="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="可填写规格、材质或现场识别说明" /> <el-select
</el-form-item> v-model="form.palletType"
filterable
allow-create
default-first-option
placeholder="请选择或输入托盘类型"
aria-label="编辑托盘类型"
>
<el-option v-for="type in palletTypeOptions" :key="type" :label="type" :value="type" />
</el-select>
</el-form-item>
</div>
<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="标准容积" />
<p class="field-help">用于服务端按总体积计算所需托盘数量,请填写托盘实际可用容积。</p>
</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-form-item>
</section>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="savePallet">保存托盘</el-button></div> <div class="drawer-footer pallet-editor-footer">
<span><i aria-hidden="true">*</i> 为必填项</span>
<div><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="savePallet">保存托盘</el-button></div>
</div>
</template> </template>
</el-drawer> </el-drawer>
</section> </section>
@@ -518,20 +544,45 @@ onMounted(() => {
.detail-hero p { margin-top: 4px; color: var(--cc-muted); font-size: 13px; } .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 { margin: 20px 0 8px; color: var(--cc-text); font-size: 14px; }
.detail-section-title:first-of-type { margin-top: 0; } .detail-section-title:first-of-type { margin-top: 0; }
.editor-context { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; padding: 12px; border: 1px solid var(--el-color-primary-light-7); border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--cc-primary); } .pallet-editor-header { display: flex; min-width: 0; align-items: center; gap: 12px; }
.editor-context div { display: grid; gap: 2px; } .pallet-editor-header > span { display: grid; width: 38px; height: 38px; flex: 0 0 auto; place-items: center; border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--cc-primary); }
.editor-context span { color: var(--cc-muted); font-size: 12px; } .pallet-editor-header h2 { margin: 0; color: #172033; font-size: 17px; line-height: 1.4; }
.pallet-editor-header p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.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-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-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; }
.editor-remark-field { margin-bottom: 0 !important; }
.field-help { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; } .field-help { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.drawer-footer { display: flex; justify-content: flex-end; gap: 8px; } .drawer-footer { display: flex; justify-content: flex-end; gap: 8px; }
.drawer-footer :deep(.el-button + .el-button) { margin-left: 0; } .drawer-footer :deep(.el-button + .el-button) { margin-left: 0; }
.pallet-editor-footer { align-items: center; justify-content: space-between; }
.pallet-editor-footer > span { color: var(--cc-muted); font-size: 11px; }
.pallet-editor-footer > span i { color: var(--el-color-danger); font-style: normal; }
.pallet-editor-footer > div { display: flex; gap: 8px; }
.pallet-editor-footer > div :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) { @media (max-width: 720px) {
.panel-heading { align-items: stretch; flex-direction: column; } .panel-heading { align-items: stretch; flex-direction: column; }
.panel-heading > .el-button { width: 100%; min-height: 44px; } .panel-heading > .el-button { width: 100%; min-height: 44px; }
.pallet-row-actions :deep(.el-button) { min-height: 36px; padding-inline: 5px; } .pallet-row-actions :deep(.el-button) { min-height: 36px; padding-inline: 5px; }
.drawer-footer { display: grid; grid-template-columns: 1fr 1fr; } .editor-form { padding-right: 16px; padding-left: 16px; }
.drawer-footer :deep(.el-button) { min-height: 44px; margin-left: 0; } .editor-field-grid { grid-template-columns: 1fr; }
.pallet-editor-footer { display: block; }
.pallet-editor-footer > span { display: none; }
.pallet-editor-footer > div { display: grid; grid-template-columns: 1fr 1fr; }
.pallet-editor-footer :deep(.el-button) { min-height: 44px; margin-left: 0; }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.pallet-filter-toolbar :deep(.query-toolbar__filters) { width: 100%; flex-direction: column; } .pallet-filter-toolbar :deep(.query-toolbar__filters) { width: 100%; flex-direction: column; }