2026-05-21-cell-product-library.md 31 KB

细胞产品库字段重设计 实施计划

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: 为细胞产品库新增代次、冻存日期、预计细胞数量、活性字段,重新定义状态枚举,实现产品编码自动生成,产品图片改为上传方式,新增查看弹窗。

Architecture: 后端在现有 Model/Schema/CRUD/API 层增量修改;前端全面重写 cell-product/index.vue 页面(表格列、搜索栏、新增/编辑弹窗、查看弹窗)。编码生成采用实时自增模式(参考 _generate_work_order_no)。

Tech Stack: Python 3.9 + FastAPI + SQLAlchemy + Pydantic v2(后端);Vue 3 + TypeScript + Element Plus + Tailwind CSS 4(前端)

Design Spec: docs/superpowers/specs/2026-05-21-cell-product-library-design.md


File Structure

操作 文件路径 职责
新增 docs/sql/2026-05-21-cell-product-alter.sql 数据库迁移:新增 4 字段 + 更新状态默认值
修改 backend/app/models/models.py (BizCellProduct 类, L2122-2149) 新增 4 个 Column,更新 product_status 默认值
修改 backend/app/schemas/cell_product.py Create/Update schema 新增字段 + 状态校验
修改 backend/app/crud/cell_product.py _row_to_dict 新增映射 + create/update 新增字段 + 新增 get_next_code
修改 backend/app/api/production.py (L1550-1632) 新增 next-code 端点 + update 端点增加状态校验
修改 web-admin/src/api/cell-product.ts 新增 fetchNextCellProductCode 接口
重写 web-admin/src/views/production/cell-product/index.vue 表格列 + 搜索 + 弹窗 + 查看弹窗 全面重写

Task 1: 数据库迁移 SQL

Files:

  • Create: docs/sql/2026-05-21-cell-product-alter.sql

  • [ ] Step 1: 编写 ALTER TABLE SQL

-- 细胞产品库字段重设计迁移
-- 日期: 2026-05-21

-- 1. 新增 4 个业务字段(放在 storage_slot_id 之后,product_status 之前)
ALTER TABLE biz_cell_product
  ADD COLUMN `passage` VARCHAR(8) DEFAULT NULL COMMENT '代次(P1~P8)' AFTER `storage_slot_id`,
  ADD COLUMN `freeze_date` DATETIME DEFAULT NULL COMMENT '冻存日期' AFTER `passage`,
  ADD COLUMN `expected_cell_count` DECIMAL(18,4) DEFAULT NULL COMMENT '预计细胞数量(单位:亿)' AFTER `freeze_date`,
  ADD COLUMN `viability` DECIMAL(5,2) DEFAULT NULL COMMENT '活性(百分比,0~100)' AFTER `expected_cell_count`;

-- 2. 更新 product_status 默认值和注释
ALTER TABLE biz_cell_product
  MODIFY COLUMN `product_status` VARCHAR(16) DEFAULT 'frozen' COMMENT '状态: frozen已冻存/sold已售卖/used已使用';

-- 3. 迁移旧状态数据(如有)
UPDATE biz_cell_product SET product_status = 'frozen' WHERE product_status = 'zk';
UPDATE biz_cell_product SET product_status = 'sold' WHERE product_status = 'yc';
UPDATE biz_cell_product SET product_status = 'used' WHERE product_status = 'ysy';
  • [ ] Step 2: 在数据库执行 SQL(由 DBA 或手动执行)

  • [ ] Step 3: 提交

git add docs/sql/2026-05-21-cell-product-alter.sql
git commit -m "feat(cell-product): add migration SQL for new fields and status enum"

Task 2: 后端 Model 更新

Files:

  • Modify: backend/app/models/models.py (BizCellProduct 类, 约第 2122-2149 行)

  • [ ] Step 1: 在 BizCellProduct 类中新增 4 个 Column,更新 product_status 默认值

在 storage_slot_id 行之后、product_status 行之前插入新字段,同时更新 product_status 默认值:

# 在 storage_slot_id 行之后新增:
    passage = Column(String(8), nullable=True, comment="代次(P1~P8)")
    freeze_date = Column(DateTime, nullable=True, comment="冻存日期")
    expected_cell_count = Column(Numeric(18, 4), nullable=True, comment="预计细胞数量(单位:亿)")
    viability = Column(Numeric(5, 2), nullable=True, comment="活性(百分比,0~100)")

# 修改 product_status 行:
    product_status = Column(String(16), nullable=True, default="frozen", comment="产品状态 frozen已冻存/sold已售卖/used已使用")
  • Step 2: 验证无语法错误

Run: cd backend && source venv/bin/activate && python -c "from app.models.models import BizCellProduct; print('OK')" Expected: OK

  • Step 3: 提交
git add backend/app/models/models.py
git commit -m "feat(cell-product): add passage, freeze_date, expected_cell_count, viability columns to model"

Task 3: 后端 Schema 更新

Files:

  • Modify: backend/app/schemas/cell_product.py

  • [ ] Step 1: 更新 CellProductCreate、CellProductUpdate、CellProductListQuery

完整替换 backend/app/schemas/cell_product.py:

from pydantic import BaseModel, field_validator
from typing import Optional
from datetime import datetime


class CellProductCreate(BaseModel):
    product_code: str
    product_name: str
    product_type_id: Optional[str] = None
    spec_id: Optional[str] = None
    batch_no: Optional[str] = None
    storage_slot_id: Optional[str] = None
    passage: Optional[str] = None
    freeze_date: Optional[datetime] = None
    expected_cell_count: Optional[float] = None
    viability: Optional[float] = None
    product_status: Optional[str] = 'frozen'
    source_type: Optional[str] = 'manual'
    quantity: Optional[float] = None
    unit: Optional[str] = None
    expiry_date: Optional[datetime] = None
    product_images: Optional[str] = None
    remark: Optional[str] = None

    @field_validator('viability')
    @classmethod
    def validate_viability(cls, v):
        if v is not None and (v < 0 or v > 100):
            raise ValueError('活性必须在 0~100 之间')
        return v

    @field_validator('passage')
    @classmethod
    def validate_passage(cls, v):
        if v is not None:
            valid = {f'P{i}' for i in range(1, 9)}
            if v.upper() not in valid:
                raise ValueError('代次必须为 P1~P8')
            return v.upper()
        return v


class CellProductUpdate(BaseModel):
    product_name: Optional[str] = None
    product_type_id: Optional[str] = None
    spec_id: Optional[str] = None
    batch_no: Optional[str] = None
    storage_slot_id: Optional[str] = None
    passage: Optional[str] = None
    freeze_date: Optional[datetime] = None
    expected_cell_count: Optional[float] = None
    viability: Optional[float] = None
    product_status: Optional[str] = None
    quantity: Optional[float] = None
    unit: Optional[str] = None
    expiry_date: Optional[datetime] = None
    product_images: Optional[str] = None
    remark: Optional[str] = None

    @field_validator('viability')
    @classmethod
    def validate_viability(cls, v):
        if v is not None and (v < 0 or v > 100):
            raise ValueError('活性必须在 0~100 之间')
        return v

    @field_validator('passage')
    @classmethod
    def validate_passage(cls, v):
        if v is not None:
            valid = {f'P{i}' for i in range(1, 9)}
            if v.upper() not in valid:
                raise ValueError('代次必须为 P1~P8')
            return v.upper()
        return v

    @field_validator('product_status')
    @classmethod
    def validate_status(cls, v):
        if v is not None and v not in ('frozen', 'sold', 'used'):
            raise ValueError('状态必须为 frozen/sold/used')
        return v


class CellProductListQuery(BaseModel):
    page: int = 1
    page_size: int = 20
    product_code: Optional[str] = None
    product_name: Optional[str] = None
    product_type_id: Optional[str] = None
    product_status: Optional[str] = None
    passage: Optional[str] = None
  • Step 2: 验证无语法错误

Run: cd backend && source venv/bin/activate && python -c "from app.schemas.cell_product import CellProductCreate, CellProductUpdate; print('OK')" Expected: OK

  • Step 3: 提交
git add backend/app/schemas/cell_product.py
git commit -m "feat(cell-product): update schemas with new fields, status validation, and passage validator"

Task 4: 后端 CRUD 更新

Files:

  • Modify: backend/app/crud/cell_product.py

  • [ ] Step 1: 更新 _row_to_dict 新增 4 个字段映射

在 _row_to_dict 函数中,在 "storageSlotId" 之后新增:

        "passage": c.passage or "",
        "freezeDate": c.freeze_date.strftime("%Y-%m-%d %H:%M:%S") if c.freeze_date else "",
        "expectedCellCount": float(c.expected_cell_count) if c.expected_cell_count is not None else None,
        "viability": float(c.viability) if c.viability is not None else None,
  • Step 2: 更新 create_cell_product 新增字段写入

在 create_cell_product 函数的 BizCellProduct(...) 构造中,在 storage_slot_id= 之后新增:

        passage=(data.passage or "").strip() or None if hasattr(data, "passage") and data.passage else None,
        freeze_date=data.freeze_date if hasattr(data, "freeze_date") else None,
        expected_cell_count=data.expected_cell_count if hasattr(data, "expected_cell_count") else None,
        viability=data.viability if hasattr(data, "viability") else None,

同时更新 product_status 默认值:

        product_status=data.product_status or "frozen",
  • Step 3: 更新 update_cell_product 新增字段处理 + 状态校验

在 update_cell_product 函数中,在 storage_slot_id 处理之后新增:

    if hasattr(data, "passage") and data.passage is not None:
        obj.passage = (data.passage or "").strip() or None
    if hasattr(data, "freeze_date") and data.freeze_date is not None:
        obj.freeze_date = data.freeze_date
    if hasattr(data, "expected_cell_count") and data.expected_cell_count is not None:
        obj.expected_cell_count = data.expected_cell_count
    if hasattr(data, "viability") and data.viability is not None:
        obj.viability = data.viability

在 product_status 处理处增加状态流转校验:

    if data.product_status is not None and data.product_status != obj.product_status:
        # 状态流转校验:只允许 frozen → sold/used,不可回退,不可互转
        allowed = {"frozen": {"sold", "used"}}
        from_status = obj.product_status
        to_status = data.product_status
        if from_status not in allowed or to_status not in allowed[from_status]:
            raise ValueError(f"不允许从状态 {from_status} 变更为 {to_status}")
        obj.product_status = data.product_status
  • Step 4: 新增 get_next_code 方法

在文件末尾 total_pages 函数之前新增:

def get_next_code(db: Session, tenant_no: str) -> str:
    """生成下一个产品编码: CP + YYYYMMDD + 6位序列号"""
    today_str = datetime.now().strftime("%Y%m%d")
    prefix = f"CP{today_str}"
    last = (
        db.query(BizCellProduct)
        .filter(
            BizCellProduct.tenant_no == tenant_no,
            BizCellProduct.product_code.like(f"{prefix}%"),
            BizCellProduct.del_flag == 0,
        )
        .order_by(BizCellProduct.product_code.desc())
        .first()
    )
    seq = 1
    if last and last.product_code:
        try:
            seq = int(last.product_code[-6:]) + 1
        except (ValueError, IndexError):
            seq = 1
    return f"{prefix}{seq:06d}"
  • Step 5: 更新 list_cell_products 支持 passage 筛选

在 list_cell_products 函数签名中新增 passage 参数,在筛选逻辑中新增:

    passage: Optional[str] = None,

在 product_status 筛选之后新增:

    if passage:
        q0 = q0.filter(BizCellProduct.passage == passage)
  • Step 6: 验证无语法错误

Run: cd backend && source venv/bin/activate && python -c "from app.crud.cell_product import get_next_code, list_cell_products; print('OK')" Expected: OK

  • Step 7: 提交
git add backend/app/crud/cell_product.py
git commit -m "feat(cell-product): update CRUD with new fields, status validation, next-code generation"

Task 5: 后端 API 更新

Files:

  • Modify: backend/app/api/production.py (约第 1550-1632 行)

  • [ ] Step 1: 新增 GET /cell-products/next-code 端点

在 list_cell_products 端点(@router.get("/cell-products"))之前新增:

@router.get("/cell-products/next-code")
async def get_next_cell_product_code(
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    tenant_no = _get_tenant_no(db, user)
    code = cell_product_svc.get_next_code(db, tenant_no)
    return success_response({"code": code})

重要: 此端点必须放在 /cell-products 之前(FastAPI 路由匹配顺序),否则 {product_id} 会匹配到 next-code。

  • Step 2: 更新 list_cell_products 端点新增 passage 参数

在端点签名中新增:

    passage: Optional[str] = None,

在调用 cell_product_svc.list_cell_products 时新增参数:

        passage=passage,
  • Step 3: 验证 API 启动无报错

Run: cd backend && source venv/bin/activate && python -c "from app.api.production import router; print('OK')" Expected: OK

  • Step 4: 提交
git add backend/app/api/production.py
git commit -m "feat(cell-product): add next-code endpoint and passage filter to list API"

Task 6: 前端 API 层更新

Files:

  • Modify: web-admin/src/api/cell-product.ts

  • [ ] Step 1: 新增 fetchNextCellProductCode 接口

在文件末尾新增:

export function fetchNextCellProductCode() {
  return request.get<{ code: string }>({ url: '/web/admin/cell-products/next-code' })
}
  • Step 2: 提交
git add web-admin/src/api/cell-product.ts
git commit -m "feat(cell-product): add fetchNextCellProductCode API"

Task 7: 前端页面全面重写

Files:

  • Rewrite: web-admin/src/views/production/cell-product/index.vue

参考模板: src/views/system/order/index.vue(复杂列表模式) 参考组件: src/views/cell-storage/protocol/ProtocolFormDialog.vue(附件上传卡片样式) 下拉数据来源:

  • 产品类型: fetchCellProductTypeList from @/api/admin
  • 规格: fetchCellProductSpecList from @/api/admin
  • 储物格: fetchStorageSlotList from @/api/admin
  • 代次: 前端硬编码 P1~P8

  • [ ] Step 1: 重写 <template> 部分

主要改动点:

  1. 搜索栏: 新增代次下拉筛选(P1~P8),状态选项改为 frozen/sold/used
  2. 表格列: 新增代次、预计细胞数量(亿)、活性(%) 三列;状态 Tag 映射更新
  3. 新增/编辑弹窗: 新增代次(下拉 P1~P8)、冻存日期(日期选择器)、预计细胞数量(数字输入)、活性(数字输入 0~100)、状态(编辑时可选);产品图片改为上传方式;产品编码新增时自动填充可编辑、编辑时只读
  4. 查看弹窗: SystemViewDialog 新增代次、冻存日期、预计细胞数量、活性字段

搜索栏状态选项更新:

<el-option label="已冻存" value="frozen" />
<el-option label="已售卖" value="sold" />
<el-option label="已使用" value="used" />

新增代次搜索筛选项:

<el-form-item label="代次">
  <el-select v-model="searchForm.passage" placeholder="全部" clearable style="width: 100%">
    <el-option v-for="i in 8" :key="i" :label="`P${i}`" :value="`P${i}`" />
  </el-select>
</el-form-item>

表格新增列(插在批次号之后、储物格位置之后):

<el-table-column prop="passage" label="代次" width="80" align="center" />
<el-table-column prop="expectedCellCount" label="预计细胞数量(亿)" min-width="140" align="center">
  <template #default="{ row }">
    {{ row.expectedCellCount ?? '—' }}
  </template>
</el-table-column>
<el-table-column prop="viability" label="活性(%)" width="90" align="center">
  <template #default="{ row }">
    {{ row.viability != null ? row.viability : '—' }}
  </template>
</el-table-column>

状态 Tag 映射更新:

function productStatusTag(status: string) {
  const map: Record<string, { label: string; type: string }> = {
    frozen: { label: '已冻存', type: 'success' },
    sold: { label: '已售卖', type: 'warning' },
    used: { label: '已使用', type: 'info' },
  }
  return map[status] || { label: status, type: 'info' }
}

表单新增字段(在储物格之后插入新行):

<el-form-item label="代次" prop="passage">
  <el-select v-model="formData.passage" placeholder="请选择代次" clearable style="width: 100%">
    <el-option v-for="i in 8" :key="i" :label="`P${i}`" :value="`P${i}`" />
  </el-select>
</el-form-item>
<el-form-item label="预计细胞数量(亿)" prop="expectedCellCount">
  <el-input-number v-model="formData.expectedCellCount" :min="0" :precision="4" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="活性(%)" prop="viability">
  <el-input-number v-model="formData.viability" :min="0" :max="100" :precision="1" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="冻存日期" prop="freezeDate">
  <el-date-picker v-model="formData.freezeDate" type="date" placeholder="请选择冻存日期" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>

状态字段(编辑时):

<el-form-item v-if="isEdit" label="状态" prop="productStatus">
  <el-select v-model="formData.productStatus" :disabled="formData.productStatus !== 'frozen'" style="width: 100%">
    <el-option label="已冻存" value="frozen" :disabled="true" />
    <el-option label="已售卖" value="sold" />
    <el-option label="已使用" value="used" />
  </el-select>
</el-form-item>

产品编码字段(新增时可编辑,编辑时只读):

<el-form-item label="产品编码" prop="productCode">
  <el-input v-model="formData.productCode" placeholder="自动生成,可修改" :readonly="isEdit" />
</el-form-item>

产品图片改为上传方式(参考 ProtocolFormDialog 附件上传卡片样式):

<el-form-item label="产品图片" class="form-col-span-2">
  <div class="image-upload">
    <div class="image-list">
      <el-upload
        ref="imageUploadRef"
        :auto-upload="false"
        :show-file-list="false"
        :multiple="true"
        accept="image/*"
        @change="handleImageChange"
      >
        <div class="upload-card">
          <el-icon :size="28" color="#10b981"><Plus /></el-icon>
          <span class="upload-card-text">上传图片</span>
        </div>
      </el-upload>
      <div
        v-for="(img, index) in imageList"
        :key="img.id || index"
        class="image-item"
      >
        <div class="image-preview" @click="handlePreviewImage(img)">
          <el-image :src="img.fileUrl" fit="cover" class="preview-img" />
        </div>
        <div class="image-footer">
          <span class="image-name">{{ img.fileName }}</span>
          <div class="image-actions">
            <el-icon class="action-icon" @click="handleDeleteImage(index)"><Close /></el-icon>
          </div>
        </div>
      </div>
    </div>
  </div>
</el-form-item>
  • Step 2: 重写 <script setup> 部分

主要改动点:

  1. import 新增: fetchNextCellProductCode from @/api/cell-product;fetchCellProductTypeList, fetchCellProductSpecList, fetchStorageSlotList from @/api/admin;fetchUploadAttachment from @/api/business
  2. formData 新增字段: passage, freezeDate, expectedCellCount, viability, productStatus
  3. searchForm 新增: passage
  4. 下拉数据加载: onMounted 中调用三个下拉接口填充 productTypeOptions、specOptions、storageSlotOptions
  5. 产品编码自动生成: handleAdd 中调用 fetchNextCellProductCode 填充
  6. 图片上传: 新增 imageList ref、handleImageChange、handleDeleteImage、handlePreviewImage
  7. 查看弹窗 sections: 新增代次、冻存日期、预计细胞数量、活性字段
  8. 编辑时填充新字段: handleEdit 中填充 passage/freezeDate/expectedCellCount/viability/productStatus
  9. 提交 payload: 包含新字段
  10. 图片列表与 formData.productImages 的同步: 上传/删除时同步更新逗号分隔 URL 字符串

下拉数据加载:

async function loadOptions() {
  try {
    const [typeRes, specRes, slotRes] = await Promise.all([
      fetchCellProductTypeList({ page: 1, pageSize: 999 }),
      fetchCellProductSpecList({ page: 1, pageSize: 999 }),
      fetchStorageSlotList(),
    ])
    productTypeOptions.value = (typeRes as any)?.list || []
    specOptions.value = (specRes as any)?.list || []
    storageSlotOptions.value = Array.isArray(slotRes) ? slotRes : (slotRes as any)?.list || []
  } catch { /* 静默 */ }
}

编码自动生成:

async function handleAdd() {
  isEdit.value = false
  dialogTitle.value = '新增产品'
  resetForm()
  dialogVisible.value = true
  formRef.value?.clearValidate?.()
  // 自动生成编码
  try {
    const res = await fetchNextCellProductCode()
    formData.productCode = res.code || ''
  } catch { /* 静默 */ }
}

图片上传处理:

const imageList = ref<Array<{ id: string; fileName: string; fileUrl: string; _uploading?: boolean }>>([])

async function handleImageChange(uploadFile: any) {
  const file = uploadFile.raw || uploadFile
  if (!file) return
  if (file.size / 1024 / 1024 > 10) {
    ElMessage.error('图片大小不能超过 10MB')
    return
  }
  const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
  imageList.value.push({ id: tempId, fileName: file.name, fileUrl: '', _uploading: true })
  const idx = imageList.value.length - 1
  try {
    const fd = new FormData()
    fd.append('folder', 'cell-product')
    fd.append('file', file)
    const res = await fetchUploadAttachment(fd)
    imageList.value[idx] = { id: res.id, fileName: res.fileName, fileUrl: res.fileUrl, _uploading: false }
  } catch {
    imageList.value.splice(idx, 1)
    ElMessage.error('图片上传失败')
  }
}

function handleDeleteImage(index: number) {
  imageList.value.splice(index, 1)
}

function handlePreviewImage(img: any) {
  if (img.fileUrl) window.open(img.fileUrl, '_blank')
}

// 同步图片 URL 到 formData
function syncImagesToForm() {
  formData.productImages = imageList.value.map((img) => img.fileUrl).filter(Boolean).join(',')
}

// 从 formData.productImages 还原 imageList
function syncFormToImages(imagesStr: string) {
  if (!imagesStr) { imageList.value = []; return }
  imageList.value = imagesStr.split(',').filter(Boolean).map((url, i) => ({
    id: `img_${i}`,
    fileName: url.split('/').pop() || `图片${i + 1}`,
    fileUrl: url,
  }))
}

查看弹窗 sections 更新:

const viewSections = computed(() => {
  if (!viewRow.value) return []
  const row = viewRow.value
  return [
    {
      title: '基本信息',
      fields: [
        { label: '产品编码', value: row.productCode },
        { label: '产品名称', value: row.productName },
        { label: '产品类型', value: row.productTypeName || '—' },
        { label: '规格', value: row.specName || '—' },
        { label: '代次', value: row.passage || '—' },
        { label: '批次号', value: row.batchNo },
        { label: '储物格位置', value: row.storageSlotName || '—' },
        { label: '预计细胞数量(亿)', value: row.expectedCellCount ?? '—' },
        { label: '活性(%)', value: row.viability != null ? row.viability : '—' },
        { label: '数量', value: row.quantity ?? '—' },
        { label: '单位', value: row.unit || '—' },
        { label: '冻存日期', value: row.freezeDate || '—' },
        { label: '有效期', value: row.expiryDate || '—' },
        {
          label: '状态',
          value: productStatusTag(row.productStatus).label,
          tagType: productStatusTag(row.productStatus).type,
        },
        { label: '备注', value: row.remark || '—', span: true },
      ],
    },
  ]
})

提交时同步图片:

async function handleSubmit() {
  await formRef.value?.validate?.()
  submitLoading.value = true
  try {
    syncImagesToForm()
    const payload = {
      productCode: formData.productCode.trim(),
      productName: formData.productName.trim(),
      productTypeId: formData.productTypeId || undefined,
      specId: formData.specId || undefined,
      batchNo: formData.batchNo.trim(),
      storageSlotId: formData.storageSlotId || undefined,
      passage: formData.passage || undefined,
      freezeDate: formData.freezeDate || undefined,
      expectedCellCount: formData.expectedCellCount ?? undefined,
      viability: formData.viability ?? undefined,
      productStatus: isEdit.value ? formData.productStatus || undefined : 'frozen',
      quantity: formData.quantity,
      unit: formData.unit?.trim() || undefined,
      expiryDate: formData.expiryDate || undefined,
      productImages: formData.productImages?.trim() || undefined,
      remark: formData.remark?.trim() || undefined,
    }
    if (isEdit.value && editingId.value) {
      await fetchUpdateCellProduct(editingId.value, payload)
    } else {
      await fetchCreateCellProduct(payload)
    }
    ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
    dialogVisible.value = false
    await loadData()
  } catch {
    /* 校验或接口错误已由全局处理 */
  } finally {
    submitLoading.value = false
  }
}

编辑时填充新字段(在 handleEdit 中):

formData.passage = row.passage || ''
formData.freezeDate = row.freezeDate || ''
formData.expectedCellCount = row.expectedCellCount ?? undefined
formData.viability = row.viability ?? undefined
formData.productStatus = row.productStatus || 'frozen'
syncFormToImages(row.productImages || '')

resetForm 新增字段重置:

function resetForm() {
  // ...existing fields...
  formData.passage = ''
  formData.freezeDate = ''
  formData.expectedCellCount = undefined
  formData.viability = undefined
  formData.productStatus = 'frozen'
  imageList.value = []
  editingId.value = null
}

搜索参数传递更新(在 loadData 中):

const res = await fetchCellProductList({
  page: pagination.page,
  pageSize: pagination.pageSize,
  keyword: searchForm.keyword || undefined,
  productStatus: searchForm.productStatus || undefined,
  passage: searchForm.passage || undefined,
})

注意:需更新 fetchCellProductList 的 params 类型以支持 productStatus 和 passage。

  • Step 3: 编写 <style> 部分(图片上传卡片样式)

在现有样式基础上新增图片上传卡片样式(参考 ProtocolFormDialog):

.image-upload {
  .image-list {
    display: flex;
    flex-wrap: wrap;
    gap: 12px;
  }

  .upload-card {
    width: 140px;
    height: 96px;
    border: 2px dashed #a7f3d0;
    border-radius: 8px;
    background: #f0fdf4;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    transition: border-color 0.2s;
    &:hover {
      border-color: #10b981;
    }
    .upload-card-text {
      margin-top: 4px;
      font-size: 12px;
      color: #64748b;
    }
  }

  .image-item {
    width: 140px;
    border: 1px solid #e2e8f0;
    border-radius: 8px;
    overflow: hidden;
    transition: border-color 0.2s;
    &:hover {
      border-color: #10b981;
    }
  }

  .image-preview {
    width: 140px;
    height: 68px;
    background: #f8fafc;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    .preview-img {
      width: 100%;
      height: 100%;
    }
  }

  .image-footer {
    padding: 4px 8px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    .image-name {
      font-size: 11px;
      color: #64748b;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      max-width: 90px;
    }
    .action-icon {
      cursor: pointer;
      color: #94a3b8;
      &:hover { color: #ef4444; }
    }
  }
}
  • Step 4: 更新 fetchCellProductList 参数类型

修改 web-admin/src/api/cell-product.ts 中的参数类型:

export function fetchCellProductList(params?: {
  page?: number
  pageSize?: number
  keyword?: string
  productStatus?: string
  passage?: string
}) {
  • Step 5: 验证前端编译无报错

Run: cd web-admin && pnpm build Expected: 构建成功

  • Step 6: 提交
git add web-admin/src/views/production/cell-product/index.vue web-admin/src/api/cell-product.ts
git commit -m "feat(cell-product): rewrite page with new fields, image upload, view dialog, and status lifecycle"

Task 8: 更新知识图谱

Files:

  • 执行: ./scripts/update-graph.sh all

  • [ ] Step 1: 更新前后端知识图谱

Run: ./scripts/update-graph.sh all Expected: 图谱更新成功

  • Step 2: 提交图谱更新
git add backend/graphify-out/ web-admin/graphify-out/
git commit -m "chore: update knowledge graphs after cell-product redesign"

Self-Review Checklist

1. Spec Coverage

Spec 需求 对应 Task
代次 P1~P8 下拉 Task 2(Model) + Task 3(Schema) + Task 7(Frontend)
冻存日期 Task 2 + Task 3 + Task 7
预计细胞数量(亿) Task 2 + Task 3 + Task 7
活性(%) 0~100 Task 2 + Task 3(Schema validator) + Task 7
状态 frozen/sold/used Task 1(SQL) + Task 2(Model) + Task 3(Schema) + Task 4(CRUD validation) + Task 7(Frontend)
产品编码自动生成 CP+YYYYMMDD+6位 Task 4(get_next_code) + Task 5(API) + Task 6(Frontend API) + Task 7(handleAdd)
产品图片上传 Task 7(Frontend image upload + SCSS)
查看弹窗 Task 7(viewSections)
状态单向流转 Task 4(update_cell_product status validation)
下拉数据来源(类型/规格/储物格) Task 7(loadOptions)
搜索筛选(关键词+状态+代次) Task 4(list_cell_products passage filter) + Task 5(API) + Task 7

2. Placeholder Scan

  • 无 TBD/TODO
  • 所有代码步骤均包含完整代码

3. Type Consistency

  • passage: Model String(8) → Schema Optional[str] → CRUD str → Frontend string
  • freeze_date: Model DateTime → Schema Optional[datetime] → Frontend string(YYYY-MM-DD)
  • expected_cell_count: Model Numeric(18,4) → Schema Optional[float] → CRUD float() → Frontend number
  • viability: Model Numeric(5,2) → Schema Optional[float] (validator 0~100) → Frontend number
  • product_status: Model String(16) default='frozen' → Schema Optional[str] (validator) → Frontend string
  • _row_to_dict 输出 camelCase key 与前端 formData 一致