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

30 KiB
Raw Blame History

库位管理页面改造 — 实现计划

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 的全部内容。

关键设计决策:

  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。

完整代码如下:

<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 中使用。