30 KiB
库位管理页面改造 — 实现计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 将库位管理页面从树形表格+右侧窄面板改为左右布局(左侧 el-tree 树形列表 + 右侧货架行格子卡片),并新增批量生成格子功能。
Architecture: 前端完全重写 index.vue,参考 user/index.vue 的 el-tree 侧边栏模式。后端新增一个批量创建格子的 API 端点(rows×cols 参数 → 一次事务创建多个 slot)。前端格子区域使用 CSS flex 按行分组展示,每行左侧有行标(A/B/C),格子内编号+状态在第一行,下方预留空间。
Tech Stack: FastAPI + SQLAlchemy(后端),Vue 3 + Element Plus + SCSS(前端)
File Structure
| File | Action | Responsibility |
|---|---|---|
backend/app/crud/storage_facility.py |
Modify | 新增 batch_create_slots() 函数 |
backend/app/api/operations.py |
Modify | 新增 POST /storage-facility-slots/batch 端点 |
web-admin/src/api/admin.ts |
Modify | 新增 fetchBatchCreateSlots() 函数 |
web-admin/src/views/operations/storage-facility/index.vue |
Rewrite | 完全重写为 el-tree + 货架行布局 |
Task 1: 后端 — 批量创建格子 CRUD 函数
Files:
-
Modify:
backend/app/crud/storage_facility.py(在文件末尾soft_delete_slot之后追加) -
Step 1: 在
storage_facility.py末尾添加batch_create_slots函数
在 backend/app/crud/storage_facility.py 文件末尾追加以下函数(在 soft_delete_slot 函数之后):
def batch_create_slots(
db: Session,
*,
facility_node_id: str,
rows: int,
cols: int,
code_prefix: str = "",
tenant_no: str = "zkxw",
user_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Batch create rows×cols slots for a leaf facility node in a single transaction."""
fid = (facility_node_id or "").strip()
if not fid:
raise ValueError("所属设施节点不能为空")
if rows < 1 or cols < 1:
raise ValueError("行数和列数必须大于 0")
if rows > 50 or cols > 50:
raise ValueError("单次最多生成 50×50 = 2500 个格子")
node = db.query(BizStorageFacility).filter(
and_(BizStorageFacility.id == fid, BizStorageFacility.del_flag == 0)
).first()
if not node:
raise ValueError("所属设施节点不存在")
if not node.is_leaf:
raise ValueError("只能为叶子节点创建格子")
prefix = (code_prefix or "").strip()
now = datetime.now()
slots: List[BizStorageFacilitySlot] = []
for r in range(1, rows + 1):
for c in range(1, cols + 1):
code = f"{prefix}{r}-{c}" if prefix else f"{r}-{c}"
order = (r - 1) * cols + c
obj = BizStorageFacilitySlot(
id=generate_uuid(),
facility_node_id=fid,
slot_code=code,
slot_status="空",
tenant_no=tenant_no,
order_num=order,
remark=None,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
slots.append(obj)
db.commit()
for s in slots:
db.refresh(s)
return [_slot_to_dict(s) for s in slots]
- Step 2: 验证语法无误
Run: cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.crud.storage_facility import batch_create_slots; print('OK')"
Expected: OK
Task 2: 后端 — 批量创建格子 API 端点
Files:
-
Modify:
backend/app/api/operations.py(在StorageSlotCreateBody类之后、list_storage_slots端点之前插入) -
Step 1: 添加 Pydantic body model 和 API 端点
在 backend/app/api/operations.py 中,在 StorageSlotCreateBody 类定义之后(约第 377 行之后),添加 body model:
class StorageSlotBatchCreateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
facility_node_id: str = Field(..., alias="facilityNodeId")
rows: int = Field(..., ge=1, le=50)
cols: int = Field(..., ge=1, le=50)
code_prefix: Optional[str] = Field("", alias="codePrefix")
然后在 @router.post("/storage-facility-slots") 端点之前(StorageSlotBatchCreateBody 定义之后),添加批量创建端点:
@router.post("/storage-facility-slots/batch")
async def batch_create_storage_slots(
body: StorageSlotBatchCreateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
items = storage_facility_svc.batch_create_slots(
db,
facility_node_id=body.facility_node_id,
rows=body.rows,
cols=body.cols,
code_prefix=body.code_prefix,
tenant_no=tenant_no,
user_id=user.get("user_id"),
)
return success_response(items, message=f"批量创建 {len(items)} 个格子成功")
except ValueError as e:
return error_response(400, str(e))
重要:这个端点必须放在 /storage-facility-slots 路由之前,否则 FastAPI 会把 batch 当做 {slot_id} 参数匹配。
- Step 2: 验证语法无误
Run: cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.api.operations import router; print('OK')"
Expected: OK
Task 3: 前端 — 添加批量创建 API 函数
Files:
-
Modify:
web-admin/src/api/admin.ts(在fetchCreateStorageSlot函数之后) -
Step 1: 在
admin.ts中fetchCreateStorageSlot之后添加批量创建函数
在 web-admin/src/api/admin.ts 中,在 fetchCreateStorageSlot 函数(第 1336-1338 行)之后插入:
export function fetchBatchCreateSlots(data: { facilityNodeId: string; rows: number; cols: number; codePrefix?: string }) {
return request.post({ url: '/web/admin/storage-facility-slots/batch', params: data })
}
- Step 2: 验证 lint 通过
Run: cd /Users/wuzilong/work/00code/zkxw/web-admin && npx eslint src/api/admin.ts --no-error-on-unmatched-pattern 2>&1 | tail -5
Expected: 无错误输出
Task 4: 前端 — 重写库位管理页面
Files:
- Rewrite:
web-admin/src/views/operations/storage-facility/index.vue - Reference:
web-admin/src/views/system/user/index.vue(el-tree 侧边栏模式) - Reference:
web-admin/storage-redesign-prototype.html(视觉原型)
这是最大的任务。完全重写页面,从树形表格+窄面板 → el-tree 侧边栏 + 货架行格子布局。
- Step 1: 重写整个 index.vue
用以下完整内容替换 web-admin/src/views/operations/storage-facility/index.vue 的全部内容。
关键设计决策:
-
左侧 el-tree — 参考
user/index.vue的w-[280px] shrink-0 border-r布局。使用el-tree组件 +buildTree()构建树形数据。树节点通过@node-click触发右侧面板更新。 -
右侧货架行格子 — 使用 flex 布局,按行分组。每行由行标
.shelf-label+ 格子容器.shelf-cells组成。每个格子.shelf-cell内编号+状态在顶部一行,下方预留空间。 -
批量生成 — 点击"批量生成格子"弹出 dialog,输入行数、列数、编码前缀。确认后调用
fetchBatchCreateSlots。 -
格子编辑 — 点击格子弹出编辑 dialog,修改编码和状态。
-
节点 CRUD — el-tree 节点 hover 显示操作按钮(新增子节点、编辑、删除),点击弹出 dialog。
完整代码如下:
<template>
<div class="page-container">
<header class="page-head">
<h1>库位管理</h1>
<p class="page-desc">维护库位设施树结构,以及叶子节点的格子管理。</p>
</header>
<section class="panel">
<div class="flex gap-4">
<div class="w-[280px] shrink-0 border-r border-[var(--mint-border)] pr-4">
<div class="panel-top sf-tree-aside__head">
<h2 class="title-bar-heading">库位树</h2>
<el-button class="btn-main" type="primary" size="small" @click="handleAddRoot">新增</el-button>
</div>
<el-input
v-model="treeKeyword"
placeholder="搜索节点"
clearable
class="sf-tree-search"
/>
<el-tree
ref="treeRef"
:data="treeData"
:props="{ label: 'nodeName', children: 'children' }"
node-key="id"
highlight-current
default-expand-all
:filter-node-method="filterNode"
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<div class="sf-tree-node" @mouseenter="hoveredNodeId = data.id" @mouseleave="hoveredNodeId = ''">
<span class="sf-tree-node__label">{{ data.nodeName }}</span>
<span v-if="data.isLeaf === 1" class="sf-tree-node__leaf">叶</span>
<div v-if="hoveredNodeId === data.id" class="sf-tree-node__ops" @click.stop>
<el-button v-if="data.isLeaf !== 1" link size="small" @click="handleAddChild(data)">+</el-button>
<el-button link size="small" @click="handleEditNode(data)">编辑</el-button>
<el-button link size="small" type="danger" @click="handleDeleteNode(data)">删除</el-button>
</div>
</div>
</template>
</el-tree>
</div>
<div class="flex-1 min-w-0">
<div v-if="!selectedNode" class="sf-empty">
<el-empty description="请在左侧选择一个叶子节点查看格子" />
</div>
<template v-if="selectedNode && selectedNode.isLeaf !== 1">
<div class="panel-top">
<h2 class="title-bar-heading">{{ selectedNode.nodeName }}</h2>
</div>
<el-empty description="非叶子节点,请继续选择子节点" />
</template>
<template v-if="selectedNode && selectedNode.isLeaf === 1">
<div class="panel-top">
<h2 class="title-bar-heading">
{{ buildTreePathLabelFromFlat(selectedNode.id, flatList, { nameKey: 'nodeName' }) }} — 格子管理
</h2>
<span class="sf-count">共 {{ slotList.length }} 个格子</span>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<el-button class="btn-main" type="primary" @click="handleBatchCreate">批量生成格子</el-button>
<el-button
v-if="slotList.length > 0"
class="btn-ghost"
@click="handleClearAllSlots"
>
清空格子
</el-button>
</div>
</div>
<div v-if="slotList.length === 0" class="sf-empty">
<el-empty description="暂无格子,点击「批量生成格子」创建" />
</div>
<div v-else class="sf-shelf-container">
<div v-for="(row, ri) in shelfRows" :key="ri" class="sf-shelf-row">
<div class="sf-shelf-label">{{ row.label }}</div>
<div class="sf-shelf-cells">
<div
v-for="cell in row.cells"
:key="cell.id"
class="sf-shelf-cell"
:class="{ 'is-occupied': cell.slotStatus === '占用' }"
@click="handleEditSlot(cell)"
>
<div class="sf-cell-top">
<span class="sf-cell-code">{{ cell.slotCode }}</span>
<span
class="sf-cell-status"
:class="cell.slotStatus === '占用' ? 'is-occupied' : 'is-empty'"
>
{{ cell.slotStatus || '空' }}
</span>
</div>
<div class="sf-cell-body">
<span class="sf-cell-reserved">—</span>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</section>
<el-dialog
v-model="nodeDialogVisible"
:title="nodeDialogTitle"
width="560px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@close="handleNodeDialogClose"
>
<el-form
ref="nodeFormRef"
:model="nodeFormData"
:rules="nodeFormRules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="上级节点">
<el-select
v-model="nodeFormData.parentId"
placeholder="请选择上级节点(空为根节点)"
clearable
filterable
>
<el-option
v-for="item in flatList"
:key="item.id"
:label="buildTreePathLabelFromFlat(item.id, flatList, { nameKey: 'nodeName' })"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="节点名称" prop="nodeName">
<el-input v-model="nodeFormData.nodeName" placeholder="请输入节点名称" />
</el-form-item>
<el-form-item label="节点类型">
<el-select v-model="nodeFormData.nodeType" placeholder="请选择节点类型">
<el-option label="设施" value="facility" />
<el-option label="层" value="layer" />
<el-option label="盒" value="box" />
</el-select>
</el-form-item>
<el-form-item label="是否叶子">
<el-switch v-model="nodeFormData.isLeaf" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="nodeFormData.remark" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="nodeDialogVisible = false">取消</el-button>
<el-button class="btn-main" type="primary" @click="handleNodeSubmit" :loading="nodeSubmitLoading">确定</el-button>
</template>
</el-dialog>
<el-dialog
v-model="slotDialogVisible"
:title="slotDialogTitle"
width="480px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
@close="handleSlotDialogClose"
>
<el-form
ref="slotFormRef"
:model="slotFormData"
:rules="slotFormRules"
label-position="top"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="格子编码" prop="slotCode">
<el-input v-model="slotFormData.slotCode" placeholder="请输入格子编码" />
</el-form-item>
<el-form-item label="格子状态">
<el-select v-model="slotFormData.slotStatus" placeholder="请选择状态">
<el-option label="空" value="空" />
<el-option label="占用" value="占用" />
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="slotDialogVisible = false">取消</el-button>
<el-button class="btn-main" type="primary" @click="handleSlotSubmit" :loading="slotSubmitLoading">确定</el-button>
</template>
</el-dialog>
<el-dialog
v-model="batchDialogVisible"
title="批量生成格子"
width="440px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top">
<div class="form-grid-2">
<el-form-item label="行数">
<el-input-number v-model="batchForm.rows" :min="1" :max="20" style="width: 100%" />
</el-form-item>
<el-form-item label="列数">
<el-input-number v-model="batchForm.cols" :min="1" :max="20" style="width: 100%" />
</el-form-item>
</div>
<el-form-item label="编码前缀">
<el-input v-model="batchForm.codePrefix" placeholder="如 A、B-、01-(可选)" />
<template #label>
编码前缀
<span class="form-label-tip">生成格式:前缀+行号-列号,如 A-1-1</span>
</template>
</el-form-item>
<div class="sf-batch-preview">
将生成 <strong>{{ batchForm.rows * batchForm.cols }}</strong> 个格子({{ batchForm.rows }} 行 × {{ batchForm.cols }} 列)
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="batchDialogVisible = false">取消</el-button>
<el-button class="btn-main" type="primary" @click="handleBatchSubmit" :loading="batchSubmitLoading">确认生成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import {
fetchStorageFacilityList,
fetchCreateStorageFacility,
fetchUpdateStorageFacility,
fetchDeleteStorageFacility,
fetchStorageSlotList,
fetchBatchCreateSlots,
fetchUpdateStorageSlot,
fetchDeleteStorageSlot
} from '@/api/admin'
import { buildTreePathLabelFromFlat } from '@/utils/tree-path'
defineOptions({ name: 'StorageFacility' })
const ROW_LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T']
const hoveredNodeId = ref('')
const treeKeyword = ref('')
const treeRef = ref()
const loading = ref(false)
const flatList = ref<any[]>([])
const treeData = ref<any[]>([])
const selectedNode = ref<any>(null)
const slotLoading = ref(false)
const slotList = ref<any[]>([])
const buildTree = (list: any[]) => {
const map = new Map<string, any>()
const roots: any[] = []
list.forEach(item => { map.set(item.id, { ...item, children: [] }) })
list.forEach(item => {
const node = map.get(item.id)!
if (item.parentId && item.parentId !== '0' && map.has(item.parentId)) {
map.get(item.parentId)!.children.push(node)
} else {
roots.push(node)
}
})
return roots
}
const loadData = async () => {
loading.value = true
try {
const res: any = await fetchStorageFacilityList({ keyword: treeKeyword.value || undefined })
const list = res?.list || res || []
flatList.value = list
treeData.value = buildTree(list)
} catch (error) {
console.error('加载库位数据失败:', error)
} finally {
loading.value = false
}
}
const filterNode = (value: string, data: any) => {
if (!value) return true
return data.nodeName?.includes(value)
}
watch(treeKeyword, val => { treeRef.value?.filter(val) })
const handleNodeClick = async (data: any) => {
selectedNode.value = data
if (data.isLeaf === 1) {
await loadSlots(data.id)
} else {
slotList.value = []
}
}
const loadSlots = async (facilityNodeId: string) => {
slotLoading.value = true
try {
const res: any = await fetchStorageSlotList({ facilityNodeId })
slotList.value = res?.list || res || []
} catch (error) {
console.error('加载格子数据失败:', error)
slotList.value = []
} finally {
slotLoading.value = false
}
}
const shelfRows = computed(() => {
const cols = batchForm.cols
const result: { label: string; cells: any[] }[] = []
for (let r = 0; r * cols < slotList.value.length; r++) {
const rowCells = slotList.value.slice(r * cols, (r + 1) * cols)
if (rowCells.length > 0) {
result.push({ label: ROW_LABELS[r] || String(r + 1), cells: rowCells })
}
}
return result
})
const nodeDialogVisible = ref(false)
const nodeDialogTitle = ref('新增根节点')
const isNodeEdit = ref(false)
const nodeSubmitLoading = ref(false)
const nodeFormRef = ref()
const nodeFormData = reactive({
id: '',
parentId: '',
nodeName: '',
nodeType: 'facility',
isLeaf: 0 as 0 | 1,
remark: ''
})
const nodeFormRules = { nodeName: [{ required: true, message: '请输入节点名称', trigger: 'blur' }] }
const handleAddRoot = () => {
isNodeEdit.value = false
nodeDialogTitle.value = '新增根节点'
Object.assign(nodeFormData, { id: '', parentId: '', nodeName: '', nodeType: 'facility', isLeaf: 0, remark: '' })
nodeDialogVisible.value = true
nextTick(() => nodeFormRef.value?.clearValidate?.())
}
const handleAddChild = (row: any) => {
isNodeEdit.value = false
nodeDialogTitle.value = '新增子节点'
Object.assign(nodeFormData, { id: '', parentId: row.id, nodeName: '', nodeType: 'layer', isLeaf: 0, remark: '' })
nodeDialogVisible.value = true
nextTick(() => nodeFormRef.value?.clearValidate?.())
}
const handleEditNode = (row: any) => {
isNodeEdit.value = true
nodeDialogTitle.value = '编辑节点'
Object.assign(nodeFormData, {
id: row.id,
parentId: row.parentId || '',
nodeName: row.nodeName || '',
nodeType: row.nodeType || 'facility',
isLeaf: row.isLeaf ?? 0,
remark: row.remark || ''
})
nodeDialogVisible.value = true
nextTick(() => nodeFormRef.value?.clearValidate?.())
}
const handleNodeSubmit = async () => {
await nodeFormRef.value?.validate?.()
nodeSubmitLoading.value = true
try {
const payload = {
parentId: nodeFormData.parentId || undefined,
nodeName: nodeFormData.nodeName.trim(),
nodeType: nodeFormData.nodeType,
isLeaf: nodeFormData.isLeaf,
remark: nodeFormData.remark?.trim() || undefined
}
if (isNodeEdit.value && nodeFormData.id) {
await fetchUpdateStorageFacility(nodeFormData.id, payload)
} else {
await fetchCreateStorageFacility(payload)
}
ElMessage.success(isNodeEdit.value ? '更新成功' : '创建成功')
nodeDialogVisible.value = false
await loadData()
} catch { /* handled globally */ } finally {
nodeSubmitLoading.value = false
}
}
const handleNodeDialogClose = () => {
Object.assign(nodeFormData, { id: '', parentId: '', nodeName: '', nodeType: 'facility', isLeaf: 0, remark: '' })
nodeFormRef.value?.clearValidate?.()
}
const handleDeleteNode = async (row: any) => {
try {
await ElMessageBox.confirm('删除节点将一并删除其子节点和格子。确定继续吗?', '提示', { type: 'warning' })
await fetchDeleteStorageFacility(row.id)
ElMessage.success('删除成功')
if (selectedNode.value?.id === row.id) {
selectedNode.value = null
slotList.value = []
}
await loadData()
} catch (error: any) {
if (error !== 'cancel') console.error('删除失败:', error)
}
}
const slotDialogVisible = ref(false)
const slotDialogTitle = ref('编辑格子')
const isSlotEdit = ref(false)
const slotSubmitLoading = ref(false)
const slotFormRef = ref()
const editingSlotId = ref<string | null>(null)
const slotFormData = reactive({ slotCode: '', slotStatus: '空' })
const slotFormRules = { slotCode: [{ required: true, message: '请输入格子编码', trigger: 'blur' }] }
const handleEditSlot = (row: any) => {
isSlotEdit.value = true
slotDialogTitle.value = '编辑格子'
editingSlotId.value = row.id
Object.assign(slotFormData, { slotCode: row.slotCode || '', slotStatus: row.slotStatus || '空' })
slotDialogVisible.value = true
nextTick(() => slotFormRef.value?.clearValidate?.())
}
const handleSlotSubmit = async () => {
await slotFormRef.value?.validate?.()
slotSubmitLoading.value = true
try {
const payload = {
facilityNodeId: selectedNode.value?.id,
slotCode: slotFormData.slotCode.trim(),
slotStatus: slotFormData.slotStatus
}
if (editingSlotId.value) {
await fetchUpdateStorageSlot(editingSlotId.value, payload)
}
ElMessage.success('更新成功')
slotDialogVisible.value = false
if (selectedNode.value?.id) await loadSlots(selectedNode.value.id)
} catch { /* handled globally */ } finally {
slotSubmitLoading.value = false
}
}
const handleSlotDialogClose = () => {
Object.assign(slotFormData, { slotCode: '', slotStatus: '空' })
editingSlotId.value = null
slotFormRef.value?.clearValidate?.()
}
const batchDialogVisible = ref(false)
const batchSubmitLoading = ref(false)
const batchForm = reactive({ rows: 4, cols: 6, codePrefix: '' })
const handleBatchCreate = () => {
Object.assign(batchForm, { rows: 4, cols: 6, codePrefix: '' })
batchDialogVisible.value = true
}
const handleBatchSubmit = async () => {
if (!selectedNode.value?.id) return
batchSubmitLoading.value = true
try {
await fetchBatchCreateSlots({
facilityNodeId: selectedNode.value.id,
rows: batchForm.rows,
cols: batchForm.cols,
codePrefix: batchForm.codePrefix || undefined
})
ElMessage.success('批量生成成功')
batchDialogVisible.value = false
await loadSlots(selectedNode.value.id)
} catch { /* handled globally */ } finally {
batchSubmitLoading.value = false
}
}
const handleClearAllSlots = async () => {
try {
await ElMessageBox.confirm(`确定要清空当前节点的全部 ${slotList.value.length} 个格子吗?`, '提示', { type: 'warning' })
for (const slot of slotList.value) {
await fetchDeleteStorageSlot(slot.id)
}
ElMessage.success('清空成功')
if (selectedNode.value?.id) await loadSlots(selectedNode.value.id)
} catch (error: any) {
if (error !== 'cancel') console.error('清空失败:', error)
}
}
onMounted(() => { loadData() })
</script>
<style lang="scss" scoped>
.sf-tree-aside__head {
display: flex;
align-items: center;
gap: 8px;
}
.sf-tree-search {
margin-bottom: 12px;
}
.sf-tree-node {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
font-size: 13px;
overflow: hidden;
}
.sf-tree-node__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sf-tree-node__leaf {
font-size: 10px;
padding: 0 4px;
border-radius: 3px;
background: var(--el-color-success-light-9, #ecfdf5);
color: var(--el-color-success, #10b981);
flex-shrink: 0;
}
.sf-tree-node__ops {
display: flex;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
}
.sf-empty {
padding: 60px 0;
}
.sf-count {
font-size: 12px;
color: var(--el-text-color-secondary, #909399);
margin-left: 8px;
}
/* Shelf layout */
.sf-shelf-container {
display: flex;
flex-direction: column;
gap: 10px;
}
.sf-shelf-row {
display: flex;
align-items: stretch;
}
.sf-shelf-label {
width: 36px;
flex-shrink: 0;
background: #059669;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
border-radius: 6px 0 0 6px;
}
.sf-shelf-cells {
display: flex;
flex: 1;
}
.sf-shelf-cell {
flex: 1;
min-height: 80px;
border: 1px solid var(--el-border-color-lighter, #d1fae5);
border-right: none;
display: flex;
flex-direction: column;
cursor: pointer;
transition: background 0.15s;
background: #fff;
padding: 8px 6px;
position: relative;
}
.sf-shelf-cell:last-child {
border-right: 1px solid var(--el-border-color-lighter, #d1fae5);
border-radius: 0 6px 6px 0;
}
.sf-shelf-cell:first-child {
border-radius: 0;
}
.sf-shelf-cell:hover {
background: #ecfdf5;
}
.sf-shelf-cell.is-occupied {
background: #fffbeb;
border-color: #fde68a;
}
.sf-shelf-cell.is-occupied:hover {
background: #fef3c7;
}
.sf-cell-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.sf-cell-code {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.sf-shelf-cell.is-occupied .sf-cell-code {
color: #92400e;
}
.sf-cell-status {
font-size: 10px;
padding: 1px 6px;
border-radius: 10px;
white-space: nowrap;
}
.sf-cell-status.is-empty {
background: #ecfdf5;
color: #059669;
}
.sf-cell-status.is-occupied {
background: #fef3c7;
color: #b45309;
}
.sf-cell-body {
flex: 1;
margin-top: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.sf-cell-reserved {
font-size: 11px;
color: #dcdfe6;
}
.sf-batch-preview {
background: #ecfdf5;
border-radius: 6px;
padding: 10px 14px;
font-size: 13px;
color: #059669;
margin-top: 8px;
}
</style>
- Step 2: 验证 lint 通过
Run: cd /Users/wuzilong/work/00code/zkxw/web-admin && npx eslint src/views/operations/storage-facility/index.vue --no-error-on-unmatched-pattern 2>&1 | tail -10
Expected: 无错误输出
- Step 3: 验证 dev server 正常
Run: cd /Users/wuzilong/work/00code/zkxw/web-admin && npx vite build --mode development 2>&1 | tail -5
Expected: 构建成功,无编译错误
Self-Review
Spec coverage:
- ✅ 左侧 el-tree 树形列表(自由层级)
- ✅ 右侧货架行布局格子展示(最末层叶子节点)
- ✅ 编号+状态在格子顶部一行
- ✅ 下方预留空间
- ✅ 批量生成格子(指定行列数)
- ✅ 格子点击编辑(dialog)
- ✅ 节点 CRUD(弹窗)
- ✅ 节点删除级联删除子节点和格子
Placeholder scan: 无 TBD/TODO/待定内容。所有代码完整。
Type consistency: 所有函数签名、变量名前后一致。fetchBatchCreateSlots 在 admin.ts 定义并在 index.vue 中使用。