This commit is contained in:
2026-08-13 20:00:38 +08:00
parent 5489bff92f
commit b9cfdcf42a
13 changed files with 183 additions and 479 deletions
+1 -2
View File
@@ -23,7 +23,6 @@ describe('admin router access metadata', () => {
it('protects phase-two inbound operations and master data with deployed permissions', () => {
const expected = [
['inbound-orders', '/inbounds', 'platform:firstleg:inbound:list'],
['inbound-order-detail', '/inbounds/:inboundId', 'platform:firstleg:inbound:view'],
['fba-carrier-rates', '/first-leg-pricing/carrier-rates', 'firstleg:fba-pricing:rate:view'],
['locations', '/master-data/locations', 'platform:location:list'],
['pallets', '/master-data/pallets', 'platform:pallet:list'],
@@ -37,7 +36,7 @@ describe('admin router access metadata', () => {
})
it('does not register removed duplicate pricing and FBA placeholder pages', () => {
const removedRouteNames = ['fba-customer-rules', 'general-fba', 'general-voyage']
const removedRouteNames = ['inbound-order-detail', 'fba-customer-rules', 'general-fba', 'general-voyage']
for (const name of removedRouteNames) {
expect(router.getRoutes().some((route) => route.name === name)).toBe(false)
@@ -1,61 +0,0 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { describe, expect, it } from 'vitest'
import InboundOrderDetailView from './InboundOrderDetailView.vue'
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/pages/InboundOrderDetailView.vue'), 'utf8')
describe('InboundOrderDetailView', () => {
it('maps the compatible route parameter to customer context without a detail-page selector', async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'dashboard', component: { template: '<div>首页</div>' } },
{ path: '/inbounds', name: 'inbound-orders', component: { template: '<div>入库单列表</div>' } },
{ path: '/inbounds/:inboundId', name: 'inbound-order-detail', component: InboundOrderDetailView },
],
})
await router.push('/inbounds/19001?tenantId=10002')
await router.isReady()
const wrapper = mount(InboundOrderDetailView, {
global: {
plugins: [router, ElementPlus],
stubs: {
InboundWorkflowWorkspace: {
props: ['customerId', 'inboundId'],
template: '<div data-testid="workspace">{{ customerId }}:{{ inboundId }}</div>',
},
},
},
})
expect(wrapper.get('[aria-label="面包屑"]').text()).toContain('首页入库单列表入库单详情')
expect(wrapper.get('[data-testid="workspace"]').text()).toBe('10002:19001')
expect(wrapper.findAll('button').filter((button) => button.text().includes('返回入库单列表'))).toHaveLength(1)
await wrapper.findAll('button').filter((button) => button.text().includes('返回入库单列表')).at(-1)?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.name).toBe('inbound-orders')
expect(router.currentRoute.value.query.tenantId).toBe('10002')
expect(source).toContain('route.query.tenantId')
expect(source).toContain('const customerId = computed')
expect(source).toContain("name: 'inbound-orders'")
expect(source).toContain(':customer-id="customerId"')
expect(source).toContain(':inbound-id="inboundId"')
expect(source).toContain('缺少所属企业信息,请从入库单列表重新打开')
expect(source).not.toContain('PlatformTenantSelector')
expect(source).not.toContain('租户')
expect(source).toContain('<PageToolbar title="入库单详情"')
expect(source).toContain('货品与测量、费用信息、状态历史及其他记录')
expect(source).toContain('class="detail-page"')
expect(source).toContain('class="detail-page-actions"')
expect(source).toContain('返回入库单列表')
expect(source).toContain('class="detail-breadcrumb"')
expect(source).not.toContain('class="detail-page-footer"')
expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('@media (max-width: 360px)')
})
})
@@ -1,75 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, ChevronRight, Home } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import InboundWorkflowWorkspace from '../components/InboundWorkflowWorkspace.vue'
const route = useRoute()
const router = useRouter()
function routeValue(value: unknown) {
if (Array.isArray(value)) return String(value[0] || '')
return value === null || value === undefined ? '' : String(value)
}
const inboundId = computed(() => routeValue(route.params.inboundId))
const customerId = computed(() => routeValue(route.query.tenantId))
function backToList() {
void router.push({ name: 'inbound-orders', query: customerId.value ? { tenantId: customerId.value } : undefined })
}
function backToHome() {
void router.push({ name: 'dashboard' })
}
</script>
<template>
<AdminPage as="div" class="business-page inbound-detail-page">
<nav class="detail-breadcrumb" aria-label="面包屑">
<button type="button" @click="backToHome"><Home :size="14" />首页</button>
<ChevronRight :size="14" aria-hidden="true" />
<button type="button" @click="backToList">入库单列表</button>
<ChevronRight :size="14" aria-hidden="true" />
<span aria-current="page">入库单详情</span>
</nav>
<DetailPageLayout class="detail-page" aria-label="入库单详情">
<template #header>
<PageToolbar title="入库单详情" description="查看基本信息、货品与测量、费用信息、状态历史及其他记录。">
<div class="detail-page-actions">
<el-button @click="backToList"><ArrowLeft :size="16" />返回入库单列表</el-button>
</div>
</PageToolbar>
</template>
<InboundWorkflowWorkspace
v-if="inboundId && customerId"
:customer-id="customerId"
:inbound-id="inboundId"
/>
<el-empty
v-else
:description="inboundId ? '缺少所属企业信息,请从入库单列表重新打开' : '缺少有效的入库单标识'"
/>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.inbound-detail-page { min-width: 0; }
.detail-breadcrumb { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 6px; padding: 0 2px; color: var(--cc-muted); font-size: 13px; }
.detail-breadcrumb button { display: inline-flex; min-height: 32px; align-items: center; gap: 5px; padding: 4px 6px; border: 0; border-radius: 4px; background: transparent; color: var(--cc-primary); cursor: pointer; font: inherit; }
.detail-breadcrumb button:hover { background: #f0f7ff; }
.detail-breadcrumb button:focus-visible { outline: 3px solid rgba(102, 126, 234, .2); outline-offset: 1px; }
.detail-page { min-width: 0; overflow: hidden; padding: 20px 24px; }
.detail-page-actions { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 12px; }
@media (max-width: 720px) {
.detail-page { padding: 16px; }
.detail-page-actions { width: 100%; align-items: stretch; flex-direction: column; }
.detail-page-actions :deep(.el-button) { width: 100%; min-height: 44px; }
}
@media (max-width: 360px) { .detail-page { padding: 12px; } }
</style>
@@ -169,7 +169,7 @@ describe('InboundOrdersView', () => {
}))
})
it('passes the selected backend tab and routes view to the independent detail page', async () => {
it('passes the selected backend tab and routes view to the unified order detail page', async () => {
const wrapper = mountPage()
const toolbar = await selectCustomer(wrapper)
toolbar.vm.$emit('search')
@@ -182,8 +182,8 @@ describe('InboundOrdersView', () => {
;[...document.querySelectorAll<HTMLButtonElement>('button')].filter((button) => button.textContent?.trim() === '查看').at(-1)?.click()
await flushPromises()
expect(routerPush).toHaveBeenCalledWith({
name: 'inbound-order-detail',
params: { inboundId: '19001' },
name: 'unified-order-detail',
params: { orderId: '18001' },
query: { tenantId: '10002' },
})
expect(wrapper.find('[aria-label="入库单详情抽屉"]').exists()).toBe(false)
@@ -334,7 +334,8 @@ describe('InboundOrdersView', () => {
expect(source).toContain('preference-key="inbound-orders-table"')
expect(columnSource.match(/defaultVisible: false/g)?.length).toBeGreaterThanOrEqual(25)
expect(toolbarSource).toMatch(/@media \(max-width: 720px\)[\s\S]*width: 100%/)
expect(source).toContain('<el-button size="small" type="primary" @click="openDetail')
expect(source).toContain('name: \'unified-order-detail\'')
expect(source).toContain(':disabled="!(row as InboundOrderSummary).orderId"')
expect(source).toContain('<ClipboardCheck :size="15" />处理')
expect(source).toContain('<el-button size="small" plain type="primary"')
expect(source).not.toContain('<el-button link type="primary" @click="openDetail(row as InboundOrderSummary)">')
@@ -283,7 +283,11 @@ function applyFilters() {
}
function openDetail(row: InboundOrderSummary) {
void router.push({ name: 'inbound-order-detail', params: { inboundId: row.id }, query: { tenantId: row.tenantId } })
if (!row.orderId) {
ElMessage.warning('该入库单尚未关联订单,暂时无法查看订单详情')
return
}
void router.push({ name: 'unified-order-detail', params: { orderId: row.orderId }, query: { tenantId: row.tenantId } })
}
async function printCartonLabels(row: InboundOrderSummary) {
if (printingInboundId.value) return
@@ -411,7 +415,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; customerRequestId +
</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 #pagination-left>当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</template>
<template #actions="{ row }"><div v-if="access.can('platform:firstleg:inbound:view')" class="row-actions"><el-button size="small" type="primary" @click="openDetail(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button size="small" plain type="primary" :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button size="small" plain type="primary" @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div><span v-else class="muted-action">无查看权限</span></template>
<template #actions="{ row }"><div v-if="access.can('platform:firstleg:inbound:view')" class="row-actions"><el-button size="small" type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button size="small" plain type="primary" :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button size="small" plain type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div><span v-else class="muted-action">无查看权限</span></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 #processStatus="{ row }"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag></template>
<template #orderStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).orderStatusSnapshot, inboundOrderStatusLabels) }}</template>
@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import DataTable from '@/shared/components/DataTable.vue'
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory } from './api'
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory, rejectMeasurement } from './api'
import MeasurementReviewPanel from './MeasurementReviewPanel.vue'
vi.mock('./api', () => ({
@@ -19,7 +19,7 @@ vi.mock('./api', () => ({
const detail = {
inboundId: '90001', inboundCode: 'RK90001', companyName: '柏悦客户', channelCode: 'SEA', channelName: '海运',
processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: 'STANDARD', reviewReason: null, reviewerId: null, reviewerName: null,
measurementId: '70001', sourceType: 'DEVICE', measurementStatus: 'SUBMITTED', version: 4,
measurementId: '70001', sourceType: 'DEVICE', measurementStatus: 'SUBMITTED', inboundVersion: 7, version: 4,
originalDocumentNo: 'ORD-001', originalGrossWeightKg: 100, originalVolumeCbm: 2, originalPackageCount: 10,
actualPackageCount: 10, actualGrossWeightKg: 110, actualVolumeCbm: 2.2, volumeWeightKg: 367.4, actualGoodsName: '测试货物',
items: [{ id: '1', boxCode: 'RK90001#10-1', itemNo: '001', boxSpecification: '60×40×30', packageCount: 10, lengthCm: 60, widthCm: 40, heightCm: 30, girthCm: 240, unitWeightKg: 11, unitVolumeCbm: 0.072, totalWeightKg: 110, totalVolumeCbm: 0.72, volumeWeightKg: 120.24, intelligentImageFileId: '30001', machineNo: 'MJ20210327', measuredAt: '2026-08-04T10:30:00+08:00', overLimitWarning: '周长超过渠道限制,请人工核验尺寸后再作出审核决定', outerLengthCm: 61, outerWidthCm: 41, outerHeightCm: 31, remark: '外箱存在轻微变形' }],
@@ -92,7 +92,26 @@ describe('MeasurementReviewPanel', () => {
'确认测量审核通过',
expect.objectContaining({ confirmButtonText: '通过并完成入库' }),
)
expect(approveMeasurement).toHaveBeenCalledWith('10001', '90001', { measurementId: '70001', expectedVersion: 4 })
expect(approveMeasurement).toHaveBeenCalledWith('10001', '90001', { measurementId: '70001', expectedVersion: 7 })
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('rejects with the inbound optimistic-lock version instead of the measurement version', async () => {
vi.mocked(rejectMeasurement).mockResolvedValue(undefined)
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const reject = wrapper.findAll('button').find((button) => button.text().includes('驳回'))
await reject?.trigger('click')
await wrapper.find('textarea').setValue('重量图片需要重新核对')
const confirm = wrapper.findAll('button').find((button) => button.text().includes('确认驳回'))
await confirm?.trigger('click')
await flushPromises()
expect(rejectMeasurement).toHaveBeenCalledWith('10001', '90001', {
measurementId: '70001',
expectedVersion: 7,
reason: '重量图片需要重新核对',
})
})
})
@@ -156,7 +156,7 @@ async function confirmApprove() {
try {
await approveMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.version,
expectedVersion: detail.value.inboundVersion,
})
ElMessage.success('审核已通过,费用发布和入库正在完成')
await reload()
@@ -187,14 +187,14 @@ async function submitReason() {
if (kind === 'reject') {
await rejectMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.version,
expectedVersion: detail.value.inboundVersion,
reason: value,
})
ElMessage.success('测量已驳回,已退回测量修正')
} else {
await releaseRejectedMeasurement(customerId.value, props.inboundId, {
measurementId: detail.value.measurementId,
expectedVersion: detail.value.version,
expectedVersion: detail.value.inboundVersion,
reason: value,
})
ElMessage.success('异常放行已提交,后续计费入库将继续处理')
+1
View File
@@ -60,6 +60,7 @@ export interface MeasurementReviewDetail {
measurementId: string
sourceType: string | null
measurementStatus: string | null
inboundVersion: number
version: number
originalDocumentNo: string | null
originalGrossWeightKg: number | null
-10
View File
@@ -31,14 +31,4 @@ export const inboundRoutes = [
permission: 'platform:firstleg:inbound:list',
},
},
{
path: 'inbounds/:inboundId',
name: 'inbound-order-detail',
component: () => import('./pages/InboundOrderDetailView.vue'),
meta: {
title: '入库作业详情',
description: '处理测量、称重、审核并查看费用和托盘库位结果',
permission: 'platform:firstleg:inbound:view',
},
},
] satisfies RouteRecordRaw[]
@@ -149,7 +149,9 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('<InboundMeasurementPanel')
expect(source).toContain('width="min(1180px, calc(100vw - 32px))"')
expect(source).toContain('measurementDialogOpen.value = true')
expect(source).not.toContain("router.push({ name: 'inbound-order-detail'")
expect(source).not.toContain("name: 'inbound-order-detail'")
expect(source).not.toContain('openInboundFromDetail')
expect(source).not.toContain('inbound-code-link')
expect(source).not.toContain('待接入:智能分柜')
expect(source).not.toContain('updateMainStatus')
expect(source).not.toContain('人工进入分柜')
@@ -654,16 +654,6 @@ function handleMeasurementChanged() {
void loadOrders()
}
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) },
})
}
type UnifiedOrderRowAction = 'note' | 'approve' | 'measurement'
const unifiedOrderRowActionIcons: Record<UnifiedOrderRowAction, Component> = {
note: StickyNote,
@@ -771,10 +761,7 @@ onMounted(() => {
<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">
<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>
<h1>{{ detail?.summary.inboundCode || (detail?.summary.inboundId ? `入库单 ${detail.summary.inboundId}` : '暂未生成') }}</h1>
<p>{{ detail?.summary.orderNo || `订单 ${orderId}` }}</p>
</div>
<StatusBadge v-if="detail" :status="statusLabel(detail.summary.mainStatus)" />
@@ -1119,8 +1106,6 @@ onMounted(() => {
.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 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; }
@@ -145,20 +145,21 @@ describe('LocationsView', () => {
it('uses bordered striped tables and explicit semantic row actions', () => {
expect(locationsSource).toContain('border')
expect(locationsSource).toContain('stripe')
expect(locationsSource).toContain('size="small" plain type="primary"')
expect(locationsSource).toMatch(/size="small"\s+plain\s+type="primary"/)
expect(locationsSource).toMatch(/size="small"\s+plain\s+:type=/)
expect(locationsSource).toMatch(/size="small"\s+plain\s+type="danger"/)
expect(locationsSource).toContain('<Eye :size="15" />')
expect(locationsSource).not.toContain('<Eye :size="15" />')
expect(locationsSource).toContain('<Pencil :size="15" />')
expect(locationsSource).toContain('<Power :size="15" />')
expect(locationsSource).toContain('<Trash2 :size="15" />')
expect(locationsSource).toContain('@media (max-width: 720px)')
})
it('uses the prototype-sized centered editor while retaining the detail drawer', () => {
expect(locationsSource).toContain("drawerMode.value === 'view' ? AppDrawer : AppDialog")
expect(locationsSource).toContain("width: 'min(560px, calc(100vw - 28px))'")
it('uses the prototype-sized centered editor without a detail drawer or section cards', () => {
expect(locationsSource).not.toContain('AppDrawer')
expect(locationsSource).not.toContain('FormSection')
expect(locationsSource).toContain('width="min(560px, calc(100vw - 28px))"')
expect(locationsSource).toContain(':global(.location-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }')
expect(locationsSource).toContain('.drawer-form :deep(.form-grid.two-cols) { grid-template-columns: minmax(0, 1fr); }')
expect(locationsSource).toContain('.editor-field-grid { grid-template-columns: minmax(0, 1fr); }')
})
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
@@ -271,7 +272,7 @@ describe('LocationsView', () => {
const bodyRows = wrapper.findAll('tbody tr')
expect(bodyRows[0]?.text()).toContain('编辑')
expect(bodyRows[1]?.text()).toContain('详情')
expect(bodyRows[1]?.text()).not.toContain('详情')
expect(bodyRows[1]?.text()).not.toContain('编辑')
expect(bodyRows[1]?.text()).not.toContain('更多')
})
@@ -280,45 +281,23 @@ describe('LocationsView', () => {
const wrapper = await mountView(['platform:location:list', 'platform:location:status'])
const row = wrapper.find('tbody tr')
expect(row.text()).toContain('详情')
expect(row.text()).not.toContain('详情')
expect(row.text()).toContain('停用')
expect(row.text()).not.toContain('更多')
})
it('keeps the first three actions visible and moves only the fourth action into more', async () => {
it('shows edit, status, and delete directly for a disabled location', async () => {
vi.mocked(listPlatformLocations).mockResolvedValue({
records: [{ ...availableLocation, status: 'DISABLED' }], page: 1, size: 20, total: 1, pages: 1,
})
const wrapper = await mountView()
const row = wrapper.find('tbody tr')
expect(row.text()).toContain('详情')
expect(row.text()).not.toContain('详情')
expect(row.text()).toContain('编辑')
expect(row.text()).toContain('启用')
expect(row.text()).not.toContain('删除')
expect(row.find('button[aria-label="A1-01的更多操作"]').exists()).toBe(true)
})
it('uses business labels without exposing internal ids in more actions', async () => {
vi.mocked(listPlatformLocations).mockResolvedValue({
records: [
{ ...availableLocation, id: 'location-1', locationCode: '', locationName: '东侧库位', status: 'DISABLED' },
{ ...availableLocation, id: 'location-2', locationCode: '', locationName: '', status: 'DISABLED' },
{ ...availableLocation, id: undefined, locationCode: '', locationName: '', status: 'DISABLED' } as unknown as PlatformLocationResource,
],
page: 1,
size: 20,
total: 3,
pages: 1,
})
const wrapper = await mountView()
const moreButtons = wrapper.findAll('button[aria-label$="的更多操作"]')
const labels = moreButtons.map((button) => button.attributes('aria-label'))
expect(labels).toEqual(['东侧库位的更多操作', '当前库位的更多操作', '当前库位的更多操作'])
expect(moreButtons.map((button) => button.attributes('title'))).toEqual(labels)
expect(labels.every((label) => Boolean(label) && !/undefined|null/.test(label ?? ''))).toBe(true)
expect(labels.join(' ')).not.toContain('location-2')
expect(row.text()).toContain('删除')
expect(row.text()).not.toContain('更多')
})
it('does not offer editing for a location outside a domestic warehouse', async () => {
@@ -328,41 +307,32 @@ describe('LocationsView', () => {
const wrapper = await mountView()
const bodyRows = wrapper.findAll('tbody tr')
expect(bodyRows[0]?.text()).toContain('详情')
expect(bodyRows[0]?.text()).not.toContain('详情')
expect(bodyRows[0]?.text()).not.toContain('编辑')
expect(bodyRows[1]?.text()).toContain('编辑')
})
it('opens an authenticated detail view with warehouse context', async () => {
it('loads the latest authenticated detail before opening the editor', async () => {
const wrapper = await mountView()
const detailButton = wrapper.find('.el-table__body-wrapper tbody tr button')
expect(detailButton.text()).toBe('详情')
await detailButton.trigger('click')
const editButton = wrapper.findAll('.el-table__body-wrapper tbody tr button').find((button) => button.text().trim() === '编辑')
await editButton!.trigger('click')
await flushPromises()
expect(getPlatformLocation).toHaveBeenCalledWith(warehouseId, availableLocation.id)
expect(document.body.textContent).toContain('库位详情')
expect(document.body.textContent).toContain('义乌收货仓(CN-YW-01)')
expect(document.body.textContent).toContain('资源作用域平台公共资源')
expect(document.body.textContent).toContain('编辑库位')
expect(document.body.textContent).toContain('A1 区 01 位')
expect(document.body.textContent).not.toContain('所属企业')
expect(document.body.textContent).not.toContain('企业 ID')
expect(document.body.textContent).toContain('库位名称A1 区 01 位')
expect(document.body.textContent).toContain('v3')
expect(document.body.querySelector('.el-drawer')).toBeNull()
})
it('presents occupied locations as read-only business resources', async () => {
vi.mocked(getPlatformLocation).mockResolvedValueOnce({
location: occupiedLocation,
warehouseCode: warehouse.warehouseCode,
warehouseName: warehouse.warehouseName,
})
it('does not provide a detail or edit entry for occupied locations', async () => {
const wrapper = await mountView()
const occupiedRow = wrapper.findAll('tbody tr')[1]
await occupiedRow!.find('button').trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('状态已占用')
expect(document.body.textContent).toContain('当前库位正在被业务使用,只允许查看。')
expect(occupiedRow?.text()).not.toContain('详情')
expect(occupiedRow?.text()).not.toContain('编辑')
expect(occupiedRow?.findAll('button')).toHaveLength(0)
})
it('clears the warehouse on reset and immediately restores the full query', async () => {
@@ -405,7 +375,7 @@ describe('LocationsView', () => {
})
})
it('keeps the latest detail when an older location request finishes last', async () => {
it('keeps the latest editor request when an older location request finishes last', async () => {
const secondLocation = { ...availableLocation, id: '9223372036854775800', locationCode: 'A1-03' }
const firstDetail = { location: availableLocation, warehouseCode: warehouse.warehouseCode, warehouseName: warehouse.warehouseName }
const secondDetail = { location: secondLocation, warehouseCode: warehouse.warehouseCode, warehouseName: warehouse.warehouseName }
@@ -417,20 +387,19 @@ describe('LocationsView', () => {
}))
const wrapper = await mountView()
const vm = wrapper.vm as unknown as {
drawerOpen: boolean
detail: typeof firstDetail | null
loadDetail(row: PlatformLocationResource, mode: 'view' | 'edit'): Promise<void>
editorOpen: boolean
form: { locationCode: string }
openEdit(row: PlatformLocationResource): Promise<void>
}
void vm.loadDetail(availableLocation, 'view')
vm.drawerOpen = false
await wrapper.vm.$nextTick()
void vm.loadDetail(secondLocation, 'view')
void vm.openEdit(availableLocation)
void vm.openEdit(secondLocation)
resolveSecond(secondDetail)
await flushPromises()
resolveFirst(firstDetail)
await flushPromises()
expect(vm.detail?.location.locationCode).toBe('A1-03')
expect(vm.editorOpen).toBe(true)
expect(vm.form.locationCode).toBe('A1-03')
})
})
@@ -1,16 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Eye, MapPin, MoreHorizontal, Pencil, Plus, Power, Save, Trash2 } from '@lucide/vue'
import { MapPin, Pencil, Plus, Power, Save, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import 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'
@@ -22,7 +19,6 @@ import {
listPlatformLocations,
updatePlatformLocation,
type EditablePlatformLocationStatus,
type PlatformLocationDetail,
type PlatformLocationQuery,
type PlatformLocationResource,
type PlatformLocationStatus,
@@ -31,7 +27,7 @@ import {
} from '../api/platformLocations'
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
type DrawerMode = 'view' | 'create' | 'edit'
type EditorMode = 'create' | 'edit'
type LocationFilters = QueryModel & {
warehouseId: string
zoneCode: string
@@ -57,17 +53,15 @@ const rows = ref<PlatformLocationResource[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const drawerOpen = ref(false)
const drawerMode = ref<DrawerMode>('view')
const drawerLoading = ref(false)
const drawerError = ref<ApiErrorDetails | null>(null)
const detail = ref<PlatformLocationDetail | null>(null)
const editorOpen = ref(false)
const editorMode = ref<EditorMode>('create')
const editorLoading = ref(false)
const saving = ref(false)
const actionLoadingIds = ref(new Set<string>())
const formRef = ref<FormInstance>()
let warehouseLoadSequence = 0
let locationLoadSequence = 0
let drawerLoadSequence = 0
let editorLoadSequence = 0
const filters = ref<LocationFilters>({ warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' })
const appliedFilters = ref<AppliedLocationFilters>({ warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' })
@@ -147,15 +141,9 @@ const selectedWarehouse = computed(() => warehouses.value.find((item) => String(
const domesticWarehouses = computed(() => warehouses.value.filter((warehouse) => (
warehouse.warehouseType === 'DOMESTIC' && warehouse.status === 'ACTIVE'
)))
const editorOpen = computed(() => drawerOpen.value && drawerMode.value !== 'view')
const editorTitle = computed(() => drawerMode.value === 'create' ? '新增库位' : '编辑库位')
const drawerTitle = computed(() => drawerMode.value === 'view' ? '库位详情' : editorTitle.value)
const drawerContainer = computed(() => drawerMode.value === 'view' ? AppDrawer : AppDialog)
const drawerContainerProps = computed(() => drawerMode.value === 'view'
? { size: 'min(620px, 94vw)', showConfirm: false }
: { width: 'min(560px, calc(100vw - 28px))', showConfirm: false, destroyOnClose: true })
const editorPermission = computed(() => drawerMode.value === 'create' ? 'platform:location:create' : 'platform:location:edit')
const selectionDisabled = computed(() => drawerOpen.value || saving.value || actionLoadingIds.value.size > 0)
const editorTitle = computed(() => editorMode.value === 'create' ? '新增库位' : '编辑库位')
const editorPermission = computed(() => editorMode.value === 'create' ? 'platform:location:create' : 'platform:location:edit')
const selectionDisabled = computed(() => editorOpen.value || editorLoading.value || saving.value || actionLoadingIds.value.size > 0)
const filtered = computed(() => Object.values(appliedFilters.value).some((value) => String(value || '').trim()))
const emptyDescription = computed(() => filtered.value ? '暂无符合筛选条件的库位' : '暂无库位数据')
@@ -199,10 +187,6 @@ function optionalText(value: string) {
return value.trim() || null
}
function formatDateTime(value?: string | null) {
return value?.replace('T', ' ').slice(0, 16) || '—'
}
function formatWeight(value: number | string | null) {
if (value === null || value === '') return '—'
const numeric = Number(value)
@@ -226,17 +210,6 @@ function canDelete(row: PlatformLocationResource) {
return access.can('platform:location:delete') && !row.sharedOverflow && row.status === 'DISABLED'
}
function hasMoreLocationActions(row: PlatformLocationResource) {
return 1 + Number(canEdit(row)) + Number(canChangeStatus(row)) + Number(canDelete(row)) > 3
}
function locationMoreActionsLabel(row: PlatformLocationResource) {
const identifier = [row.locationCode, row.locationName]
.map((value) => String(value ?? '').trim())
.find(Boolean) || '当前库位'
return `${identifier}的更多操作`
}
function actionError(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
@@ -341,43 +314,37 @@ function assignForm(resource: PlatformLocationResource) {
}
function openCreate() {
editorLoadSequence += 1
Object.assign(form, emptyForm())
form.warehouseId = isActiveDomesticWarehouse(selectedWarehouseId.value) ? selectedWarehouseId.value : ''
form.status = selectedWarehouse.value?.status === 'INACTIVE' ? 'DISABLED' : 'AVAILABLE'
detail.value = null
drawerError.value = null
drawerMode.value = 'create'
editorMode.value = 'create'
captureBaseline()
drawerOpen.value = true
editorOpen.value = true
void formRef.value?.clearValidate()
}
async function loadDetail(row: PlatformLocationResource, mode: 'view' | 'edit') {
if (mode === 'edit' && !isDomesticWarehouse(row.warehouseId)) {
async function openEdit(row: PlatformLocationResource) {
if (!isDomesticWarehouse(row.warehouseId)) {
ElMessage.warning('仅支持编辑国内仓库位')
return
}
const sequence = ++drawerLoadSequence
const sequence = ++editorLoadSequence
const scope = recordScope(row)
drawerMode.value = mode
drawerOpen.value = true
drawerLoading.value = true
drawerError.value = null
detail.value = null
editorLoading.value = true
try {
const result = await getPlatformLocation(scope.warehouseId, row.id)
if (sequence !== drawerLoadSequence || !drawerOpen.value) return
detail.value = result
if (mode === 'edit') {
assignForm(result.location)
captureBaseline()
void formRef.value?.clearValidate()
}
if (sequence !== editorLoadSequence) return
editorMode.value = 'edit'
assignForm(result.location)
captureBaseline()
editorOpen.value = true
void formRef.value?.clearValidate()
} catch (error) {
if (sequence !== drawerLoadSequence) return
drawerError.value = getApiErrorDetails(error, '库位详情加载失败,请稍后重试')
if (sequence !== editorLoadSequence) return
actionError(error, '库位资料加载失败,请稍后重试')
} finally {
if (sequence === drawerLoadSequence) drawerLoading.value = false
if (sequence === editorLoadSequence) editorLoading.value = false
}
}
@@ -413,7 +380,7 @@ async function saveLocation() {
const payload = buildPayload()
if (form.id) await updatePlatformLocation(scope.warehouseId, form.id, payload)
else await createPlatformLocation(payload)
drawerOpen.value = false
editorOpen.value = false
ElMessage.success(form.id ? '库位资料已更新' : '库位已创建')
await loadLocations()
} catch (error) {
@@ -485,30 +452,23 @@ async function removeLocation(row: PlatformLocationResource) {
}
}
function handleMoreAction(row: PlatformLocationResource, command: string) {
if (command === 'status') void toggleStatus(row)
if (command === 'delete') void removeLocation(row)
}
watch(
() => access.can(editorPermission.value),
(allowed) => {
if (allowed || !editorOpen.value) return
drawerOpen.value = false
editorOpen.value = false
},
{ flush: 'sync' },
)
watch([page, pageSize], () => { void loadLocations() })
watch(drawerOpen, (open) => {
watch(editorOpen, (open) => {
if (open) return
drawerLoadSequence += 1
drawerLoading.value = false
drawerError.value = null
detail.value = null
editorLoadSequence += 1
editorLoading.value = false
})
watch(() => form.warehouseId, (warehouseId) => {
if (drawerMode.value !== 'create') return
if (editorMode.value !== 'create') return
const warehouse = warehouses.value.find((item) => String(item.id) === warehouseId)
form.status = warehouse?.status === 'INACTIVE' ? 'DISABLED' : 'AVAILABLE'
})
@@ -570,16 +530,14 @@ onMounted(() => {
<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 size="small" plain type="primary" :disabled="loading" @click="loadDetail(row as PlatformLocationResource, 'view')">
<Eye :size="15" />详情
</el-button>
<el-button
v-if="canEdit(row as PlatformLocationResource)"
size="small"
plain
type="primary"
:disabled="loading"
@click="loadDetail(row as PlatformLocationResource, 'edit')"
:loading="editorLoading"
:disabled="loading || editorLoading"
@click="openEdit(row as PlatformLocationResource)"
><Pencil :size="15" />编辑</el-button>
<el-button
v-if="canChangeStatus(row as PlatformLocationResource)"
@@ -590,29 +548,13 @@ onMounted(() => {
@click="toggleStatus(row as PlatformLocationResource)"
><Power :size="15" />{{ (row as PlatformLocationResource).status === 'AVAILABLE' ? '停用' : '启用' }}</el-button>
<el-button
v-if="canDelete(row as PlatformLocationResource) && !hasMoreLocationActions(row as PlatformLocationResource)"
v-if="canDelete(row as PlatformLocationResource)"
size="small"
plain
type="danger"
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
@click="removeLocation(row as PlatformLocationResource)"
><Trash2 :size="15" />删除</el-button>
<el-dropdown
v-if="hasMoreLocationActions(row as PlatformLocationResource)"
trigger="click"
popper-class="location-action-menu"
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
@command="handleMoreAction(row as PlatformLocationResource, String($event))"
>
<el-button size="small" plain type="primary" :aria-label="locationMoreActionsLabel(row as PlatformLocationResource)" :title="locationMoreActionsLabel(row as PlatformLocationResource)"><MoreHorizontal :size="16" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="canDelete(row as PlatformLocationResource)" command="delete" class="location-action-menu__danger">
<Trash2 :size="15" />删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<template #location="{ row }">
@@ -656,77 +598,21 @@ onMounted(() => {
</section>
</ListPageLayout>
<component
:is="drawerContainer"
v-model="drawerOpen"
:title="drawerTitle"
v-bind="drawerContainerProps"
:class="drawerMode === 'view' ? 'location-drawer' : 'location-editor-dialog'"
:loading="drawerLoading"
<AppDialog
v-model="editorOpen"
:title="editorTitle"
width="min(560px, calc(100vw - 28px))"
class="location-editor-dialog"
:confirm-loading="saving"
:close-on-click-modal="false"
:close-on-press-escape="!saving"
:show-close="!saving"
:show-confirm="false"
destroy-on-close
>
<div class="drawer-content">
<el-alert v-if="drawerError" type="error" :closable="false" show-icon title="库位详情加载失败">
<p>{{ drawerError.message }}</p>
<ApiErrorMeta :details="drawerError" />
</el-alert>
<template v-else-if="drawerMode === 'view' && detail">
<section class="detail-identity">
<div class="detail-identity__icon"><MapPin :size="24" /></div>
<div>
<h3>{{ detail.location.locationCode }}</h3>
<p>{{ detail.location.locationName || '未命名库位' }}</p>
</div>
<el-tag :type="statusMeta[detail.location.status].type">{{ statusMeta[detail.location.status].label }}</el-tag>
</section>
<el-descriptions :column="2" border class="detail-descriptions">
<el-descriptions-item label="资源作用域">平台公共资源</el-descriptions-item>
<el-descriptions-item label="所属仓库">{{ detail.warehouseName }}({{ detail.warehouseCode }})</el-descriptions-item>
<el-descriptions-item label="库位编码">{{ detail.location.locationCode }}</el-descriptions-item>
<el-descriptions-item label="库位名称">{{ detail.location.locationName || '—' }}</el-descriptions-item>
<el-descriptions-item label="库区">{{ detail.location.zoneName }}({{ detail.location.zoneCode }})</el-descriptions-item>
<el-descriptions-item label="库位类型">{{ typeLabels[detail.location.locationType] }}</el-descriptions-item>
<el-descriptions-item label="最大承重">{{ formatWeight(detail.location.maxWeightKg) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusMeta[detail.location.status].label }}</el-descriptions-item>
<el-descriptions-item label="资源版本">v{{ detail.location.version }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(detail.location.updatedAt) }}</el-descriptions-item>
<el-descriptions-item label="系统共享位">{{ detail.location.sharedOverflow ? '是,仅系统自动分配' : '否' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ detail.location.remark || '—' }}</el-descriptions-item>
</el-descriptions>
<el-alert
v-if="detail.location.status === 'RESERVED' || detail.location.status === 'OCCUPIED'"
type="warning"
:closable="false"
show-icon
title="当前库位正在被业务使用,只允许查看。"
/>
<el-alert
v-if="detail.location.sharedOverflow"
type="info"
:closable="false"
show-icon
title="A0 是系统共享溢出位,不能编辑、停用或删除。"
/>
</template>
<el-form
v-else-if="drawerMode !== 'view'"
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="drawer-form"
:disabled="saving"
>
<div v-if="drawerMode === 'edit'" class="editor-scope">
<MapPin :size="18" />
<div><strong>{{ detail?.warehouseName || selectedWarehouse?.warehouseName }}({{ detail?.warehouseCode || selectedWarehouse?.warehouseCode }})</strong><span>平台公共资源 · 保存后不可跨仓移动</span></div>
</div>
<el-form-item v-if="drawerMode === 'create'" label="所属仓库" prop="warehouseId" required>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="editor-form" :disabled="saving">
<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"
@@ -736,66 +622,57 @@ onMounted(() => {
/>
</el-select>
</el-form-item>
<FormSection title="库位标识" description="库位编码在当前仓库内唯一;编辑后不能移动到其他仓库。">
<div class="form-grid two-cols">
<el-form-item label="库位编码" prop="locationCode" required>
<el-input v-model.trim="form.locationCode" maxlength="50" placeholder="例如 A1-01" />
</el-form-item>
<el-form-item label="库位名称">
<el-input v-model.trim="form.locationName" maxlength="100" placeholder="例如 A1 区 01 位" />
</el-form-item>
<el-form-item label="库区编码" prop="zoneCode" required>
<el-input v-model.trim="form.zoneCode" maxlength="50" placeholder="例如 A" />
</el-form-item>
<el-form-item label="库区名称" prop="zoneName" required>
<el-input v-model.trim="form.zoneName" maxlength="100" placeholder="例如 常规区" />
</el-form-item>
</div>
</FormSection>
<FormSection title="资源属性">
<div class="form-grid two-cols">
<el-form-item label="库位类型" prop="locationType" required>
<el-select v-model="form.locationType" placeholder="请选择库位类型">
<el-option label="货架位" value="SHELF" />
<el-option label="托盘位" value="PALLET" />
<el-option label="地堆位" value="FLOOR" />
<el-option label="流利架" value="FLOW_RACK" />
</el-select>
</el-form-item>
<el-form-item label="最大承重(kg)">
<el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" :step="10" controls-position="right" placeholder="选填" />
</el-form-item>
</div>
<el-form-item v-if="drawerMode === 'create'" label="初始状态" prop="status" required>
<el-radio-group v-model="form.status">
<el-radio-button value="AVAILABLE" :disabled="selectedWarehouse?.status !== 'ACTIVE'">可用</el-radio-button>
<el-radio-button value="DISABLED">停用</el-radio-button>
</el-radio-group>
<p v-if="selectedWarehouse?.status !== 'ACTIVE'" class="form-help">当前仓库已停用,新库位只能以停用状态创建。</p>
</el-form-item>
<div v-else class="readonly-status">
<span>当前状态</span>
<el-tag :type="statusMeta[form.status].type">{{ statusMeta[form.status].label }}</el-tag>
<small>状态只能通过列表中的启停操作变更。</small>
</div>
</FormSection>
<FormSection title="备注">
<el-form-item label="内部说明">
<el-input v-model.trim="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入库位位置、使用限制或其他说明" />
</el-form-item>
</FormSection>
</el-form>
</div>
<el-form-item label="库位编码" prop="locationCode" required>
<el-input v-model.trim="form.locationCode" maxlength="50" placeholder="例如 A1-01" />
</el-form-item>
</div>
<div class="editor-field-grid">
<el-form-item label="库位名称">
<el-input v-model.trim="form.locationName" maxlength="100" placeholder="例如 A1 区 01 位" />
</el-form-item>
<el-form-item label="库区编码" prop="zoneCode" required>
<el-input v-model.trim="form.zoneCode" maxlength="50" placeholder="例如 A" />
</el-form-item>
</div>
<div class="editor-field-grid">
<el-form-item label="库区名称" prop="zoneName" required>
<el-input v-model.trim="form.zoneName" maxlength="100" placeholder="例如 常规区" />
</el-form-item>
<el-form-item label="库位类型" prop="locationType" required>
<el-select v-model="form.locationType" placeholder="请选择库位类型">
<el-option label="货架位" value="SHELF" />
<el-option label="托盘位" value="PALLET" />
<el-option label="地堆位" value="FLOOR" />
<el-option label="流利架" value="FLOW_RACK" />
</el-select>
</el-form-item>
</div>
<div class="editor-field-grid">
<el-form-item label="最大承重(kg)">
<el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" :step="10" controls-position="right" placeholder="选填" />
</el-form-item>
<el-form-item label="状态" prop="status" required>
<el-select v-model="form.status" :disabled="editorMode === 'edit'" placeholder="请选择状态">
<el-option label="可用" value="AVAILABLE" :disabled="selectedWarehouse?.status !== 'ACTIVE'" />
<el-option label="停用" value="DISABLED" />
</el-select>
</el-form-item>
</div>
<el-form-item label="备注" class="editor-remark-field">
<el-input v-model.trim="form.remark" maxlength="500" show-word-limit placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'view' ? '关闭' : '取消' }}</el-button>
<el-button v-if="drawerMode !== 'view'" type="primary" :loading="saving" @click="saveLocation"><Save :size="16" />保存库位</el-button>
<div class="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="saveLocation"><Save :size="16" />保存库位</el-button>
</div>
</div>
</template>
</component>
</AppDialog>
</AdminPage>
</template>
@@ -810,34 +687,27 @@ onMounted(() => {
.location-cell strong, .stacked-value strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.location-cell small, .stacked-value small { color: var(--cc-muted); font-size: 11px; }
.remark-cell { display: -webkit-box; overflow: hidden; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.drawer-content { min-height: 260px; }
:global(.location-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
:global(.location-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
:global(.location-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
.drawer-content > .el-alert + .el-alert, .detail-descriptions + .el-alert { margin-top: 14px; }
.detail-identity { display: grid; grid-template-columns: 48px minmax(0, 1fr) auto; align-items: center; gap: 12px; margin-bottom: 18px; }
.detail-identity__icon { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 10px; background: #eef3fa; color: var(--cc-primary); }
.detail-identity h3 { margin: 0; font-size: 18px; }
.detail-identity p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
.detail-descriptions :deep(.el-descriptions__label) { width: 118px; }
.section-description { margin: -3px 0 14px; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.drawer-form :deep(.el-input-number), .drawer-form :deep(.el-select) { width: 100%; }
.editor-scope { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; padding: 12px; background: #f7f9fc; color: var(--cc-primary); }
.editor-scope div { display: grid; min-width: 0; gap: 2px; }
.editor-scope strong, .editor-scope span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.editor-scope span { color: var(--cc-muted); font-size: 12px; }
.form-help { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.readonly-status { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 12px; background: #f7f9fc; }
.readonly-status > span { color: var(--cc-text); font-size: 13px; font-weight: 600; }
.readonly-status small { color: var(--cc-muted); font-size: 11px; }
:global(.location-action-menu .location-action-menu__danger:not(.is-disabled)) { color: var(--el-color-danger); }
.editor-form { padding: 8px 24px 12px; }
.editor-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
.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; }
.editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.editor-footer > span { color: var(--cc-muted); font-size: 11px; }
.editor-footer > span i { color: var(--el-color-danger); font-style: normal; }
.editor-footer > div { display: flex; gap: 8px; }
.editor-footer :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) {
.drawer-form :deep(.form-grid.two-cols) { grid-template-columns: minmax(0, 1fr); }
.detail-identity { grid-template-columns: 44px minmax(0, 1fr); }
.detail-identity > .el-tag { grid-column: 2; justify-self: start; }
.detail-descriptions :deep(.el-descriptions__body), .detail-descriptions :deep(table), .detail-descriptions :deep(tbody) { display: block; width: 100%; }
.detail-descriptions :deep(tr) { display: grid; grid-template-columns: 108px minmax(0, 1fr); }
.detail-descriptions :deep(.el-descriptions__cell) { display: block; width: auto; }
.editor-form { padding-right: 16px; padding-left: 16px; }
.editor-field-grid { grid-template-columns: minmax(0, 1fr); }
.editor-footer { display: block; }
.editor-footer > span { display: none; }
.editor-footer > div { display: grid; grid-template-columns: 1fr 1fr; }
.editor-footer :deep(.el-button) { min-height: 44px; margin-left: 0; }
}
</style>