Files
zkxw/docs/superpowers/plans/2026-05-15-storage-facility-redesign.md
T
2026-05-17 08:36:37 +08:00

925 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 库位管理页面改造 — 实现计划
> **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` 函数之后):
```python
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:
```python
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` 定义之后),添加批量创建端点:
```python
@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 行)之后插入:
```typescript
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` 的全部内容。
**关键设计决策:**
1. **左侧 el-tree** — 参考 `user/index.vue` 的 `w-[280px] shrink-0 border-r` 布局。使用 `el-tree` 组件 + `buildTree()` 构建树形数据。树节点通过 `@node-click` 触发右侧面板更新。
2. **右侧货架行格子** — 使用 flex 布局,按行分组。每行由行标 `.shelf-label` + 格子容器 `.shelf-cells` 组成。每个格子 `.shelf-cell` 内编号+状态在顶部一行,下方预留空间。
3. **批量生成** — 点击"批量生成格子"弹出 dialog,输入行数、列数、编码前缀。确认后调用 `fetchBatchCreateSlots`。
4. **格子编辑** — 点击格子弹出编辑 dialog,修改编码和状态。
5. **节点 CRUD** — el-tree 节点 hover 显示操作按钮(新增子节点、编辑、删除),点击弹出 dialog。
**完整代码如下:**
```vue
<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 中使用。