925 lines
30 KiB
Markdown
925 lines
30 KiB
Markdown
# 库位管理页面改造 — 实现计划
|
||
|
||
> **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 中使用。
|