提交
This commit is contained in:
1 parent
ad0e849f6d
commit
14f1a84bb3
32 files changed
+8200
-5194
No files matched your search
@@ -0,0 +1,913 @@
|
||||
# 细胞产品库字段重设计 实施计划
|
||||
|
||||
> **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**
|
||||
|
||||
```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: 提交**
|
||||
|
||||
```bash
|
||||
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` 默认值:
|
||||
|
||||
```python
|
||||
# 在 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: 提交**
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```python
|
||||
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: 提交**
|
||||
|
||||
```bash
|
||||
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"` 之后新增:
|
||||
|
||||
```python
|
||||
"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=` 之后新增:
|
||||
|
||||
```python
|
||||
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` 默认值:
|
||||
```python
|
||||
product_status=data.product_status or "frozen",
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 更新 `update_cell_product` 新增字段处理 + 状态校验**
|
||||
|
||||
在 `update_cell_product` 函数中,在 `storage_slot_id` 处理之后新增:
|
||||
|
||||
```python
|
||||
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` 处理处增加状态流转校验:
|
||||
|
||||
```python
|
||||
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` 函数之前新增:
|
||||
|
||||
```python
|
||||
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` 参数,在筛选逻辑中新增:
|
||||
|
||||
```python
|
||||
passage: Optional[str] = None,
|
||||
```
|
||||
|
||||
在 `product_status` 筛选之后新增:
|
||||
|
||||
```python
|
||||
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: 提交**
|
||||
|
||||
```bash
|
||||
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")`)**之前**新增:
|
||||
|
||||
```python
|
||||
@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 参数**
|
||||
|
||||
在端点签名中新增:
|
||||
|
||||
```python
|
||||
passage: Optional[str] = None,
|
||||
```
|
||||
|
||||
在调用 `cell_product_svc.list_cell_products` 时新增参数:
|
||||
|
||||
```python
|
||||
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: 提交**
|
||||
|
||||
```bash
|
||||
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` 接口**
|
||||
|
||||
在文件末尾新增:
|
||||
|
||||
```typescript
|
||||
export function fetchNextCellProductCode() {
|
||||
return request.get<{ code: string }>({ url: '/web/admin/cell-products/next-code' })
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
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 新增代次、冻存日期、预计细胞数量、活性字段
|
||||
|
||||
**搜索栏状态选项更新**:
|
||||
```html
|
||||
<el-option label="已冻存" value="frozen" />
|
||||
<el-option label="已售卖" value="sold" />
|
||||
<el-option label="已使用" value="used" />
|
||||
```
|
||||
|
||||
**新增代次搜索筛选项**:
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
**表格新增列**(插在批次号之后、储物格位置之后):
|
||||
```html
|
||||
<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 映射更新**:
|
||||
```typescript
|
||||
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' }
|
||||
}
|
||||
```
|
||||
|
||||
**表单新增字段**(在储物格之后插入新行):
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
**状态字段(编辑时)**:
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
**产品编码字段**(新增时可编辑,编辑时只读):
|
||||
```html
|
||||
<el-form-item label="产品编码" prop="productCode">
|
||||
<el-input v-model="formData.productCode" placeholder="自动生成,可修改" :readonly="isEdit" />
|
||||
</el-form-item>
|
||||
```
|
||||
|
||||
**产品图片改为上传方式**(参考 ProtocolFormDialog 附件上传卡片样式):
|
||||
|
||||
```html
|
||||
<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 字符串
|
||||
|
||||
**下拉数据加载**:
|
||||
```typescript
|
||||
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 { /* 静默 */ }
|
||||
}
|
||||
```
|
||||
|
||||
**编码自动生成**:
|
||||
```typescript
|
||||
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 { /* 静默 */ }
|
||||
}
|
||||
```
|
||||
|
||||
**图片上传处理**:
|
||||
```typescript
|
||||
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 更新**:
|
||||
```typescript
|
||||
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 },
|
||||
],
|
||||
},
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
**提交时同步图片**:
|
||||
```typescript
|
||||
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` 中):
|
||||
```typescript
|
||||
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 新增字段重置**:
|
||||
```typescript
|
||||
function resetForm() {
|
||||
// ...existing fields...
|
||||
formData.passage = ''
|
||||
formData.freezeDate = ''
|
||||
formData.expectedCellCount = undefined
|
||||
formData.viability = undefined
|
||||
formData.productStatus = 'frozen'
|
||||
imageList.value = []
|
||||
editingId.value = null
|
||||
}
|
||||
```
|
||||
|
||||
**搜索参数传递更新**(在 `loadData` 中):
|
||||
```typescript
|
||||
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):
|
||||
|
||||
```scss
|
||||
.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` 中的参数类型:
|
||||
|
||||
```typescript
|
||||
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: 提交**
|
||||
|
||||
```bash
|
||||
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: 提交图谱更新**
|
||||
|
||||
```bash
|
||||
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 一致
|
||||
Reference in new issue
Block a user