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: 质检在细胞存储质检工单卡片填写「储存证质检信息」,市场在样本信息管理只读查看;数据按样本级 JSON 落库,字段对齐 Excel 两套模板。
Architecture: 新表 biz_sample_storage_cert_qc(tenant_no + sample_code upsert,payload Text JSON)。服务层解析样本类型 → perinatal_hsc / uc_msc;API GET/PUT /web/admin/sample-storage-cert-qc/{sample_code}。前端共用字段元数据 + 弹窗组件;工单卡片可编辑,样本信息行操作只读。
Tech Stack: MySQL 5.7 / FastAPI + SQLAlchemy / pytest unittest / Vue 3 + Element Plus / pnpm
Design Doc: docs/superpowers/specs/2026-10-09-storage-cert-qc-info-design.md
Demo: docs/demos/2026-10-09-storage-cert-qc-info-demo.html
Reuse:
backend/app/crud/sample_production_reception.py 的 SAMPLE_TYPE_PERINATAL_HSC / SAMPLE_TYPE_UC_MSC(可抽到 service 避免循环 import,或在新 service 内重复同名字符串常量)web-admin/src/utils/sample-production-reception-fields.tsrequire_any_menu_code(质检 PUT:biz:qcWorkOrder:list | biz:myQcTask:list;GET 另含 cell-storage:sample-info:list)app.core.utils.generate_uuidQcWorkOrderSingleCard.vue;样本列表:UnifiedSampleInfoPage.vue| File | Responsibility |
|---|---|
docs/sql/migrations/2026-10-09-sample-storage-cert-qc.sql |
建表 |
backend/app/services/sample_storage_cert_qc.py |
类型键映射、payload 规范化、empty GET 形态 |
backend/app/crud/sample_storage_cert_qc.py |
get / upsert / row_to_dict |
backend/app/api/sample_storage_cert_qc.py |
GET + PUT |
backend/tests/test_sample_storage_cert_qc.py |
纯函数 + upsert 辅助单测 |
web-admin/src/api/sample-storage-cert-qc.ts |
FE API |
web-admin/src/utils/storage-cert-qc-fields.ts |
两套模板字段元数据 + 类型判定 |
web-admin/src/components/business/storage-cert-qc-dialog/index.vue |
填写 / 查看共用弹窗 |
| File | Change |
|---|---|
backend/app/models/models.py |
新增 BizSampleStorageCertQc |
backend/app/api/__init__.py |
export router |
backend/app/main.py |
include_router |
web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderSingleCard.vue |
按钮 + 打开弹窗 |
web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue |
行操作查看按钮 + 弹窗 |
Files:
docs/sql/migrations/2026-10-09-sample-storage-cert-qc.sqlModify: backend/app/models/models.py(建议插在 BizProtocolStorageCert 附近)
[ ] Step 1: 写迁移 SQL
-- 样本级储存证质检信息(2026-10-09)
-- 对齐 docs/superpowers/specs/2026-10-09-storage-cert-qc-info-design.md
CREATE TABLE IF NOT EXISTS biz_sample_storage_cert_qc (
id VARCHAR(64) NOT NULL COMMENT '主键ID',
sample_code VARCHAR(32) NOT NULL COMMENT '样本编码',
collection_protocol_id VARCHAR(64) DEFAULT NULL COMMENT '采集协议单ID冗余',
qc_work_order_id VARCHAR(64) DEFAULT NULL COMMENT '最近填写来源工单ID',
sample_type_key VARCHAR(32) NOT NULL COMMENT 'perinatal_hsc|uc_msc',
payload TEXT NOT NULL COMMENT '业务字段 JSON',
filled_flag CHAR(1) NOT NULL DEFAULT '0' COMMENT '是否已保存过 0否1是',
tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
order_num INT DEFAULT NULL COMMENT '排序号',
remark VARCHAR(512) DEFAULT NULL COMMENT '备注',
create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
update_time DATETIME NOT NULL COMMENT '更新时间',
del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
PRIMARY KEY (id),
UNIQUE KEY uk_storage_cert_qc_tenant_sample (tenant_no, sample_code, del_flag),
KEY idx_storage_cert_qc_protocol (tenant_no, collection_protocol_id, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='样本储存证质检信息';
注意:MySQL 5.7 下 UNIQUE (tenant_no, sample_code, del_flag) 在多次软删时可能冲突(del_flag=1 多行)。若担心,可改为仅 (tenant_no, sample_code) 唯一、且业务禁止物理多行软删副本(upsert 只更新同一行,删除走 del_flag=1 且不再新建同码行);或应用层保证唯一、库上只建普通索引 KEY idx_storage_cert_qc_sample (tenant_no, sample_code, del_flag)。
采用(与约定一致、实现简单): 唯一索引改为:
UNIQUE KEY uk_storage_cert_qc_tenant_sample (tenant_no, sample_code),
软删后若需再建同码行,先清理或复用该行(本功能 upsert 始终复用,不删)。
在 models.py 增加:
class BizSampleStorageCertQc(Base):
"""样本储存证质检信息 biz_sample_storage_cert_qc"""
__tablename__ = "biz_sample_storage_cert_qc"
id = Column(String(64), primary_key=True, comment="主键ID")
sample_code = Column(String(32), nullable=False, comment="样本编码")
collection_protocol_id = Column(String(64), nullable=True, comment="采集协议单ID冗余")
qc_work_order_id = Column(String(64), nullable=True, comment="最近填写来源工单ID")
sample_type_key = Column(String(32), nullable=False, comment="perinatal_hsc|uc_msc")
payload = Column(Text, nullable=False, comment="业务字段 JSON")
filled_flag = Column(CHAR(1), nullable=False, default="0", comment="是否已保存过 0否1是")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
确保文件顶部已 from sqlalchemy import ... CHAR(若无则加入)。
git add docs/sql/migrations/2026-10-09-sample-storage-cert-qc.sql backend/app/models/models.py
git commit -m "$(cat <<'EOF'
feat(db): add biz_sample_storage_cert_qc for storage-cert QC payload.
EOF
)"
Files:
backend/app/services/sample_storage_cert_qc.pyCreate: backend/tests/test_sample_storage_cert_qc.py
[ ] Step 1: 写失败单测
"""储存证质检信息:类型键与 payload 规范化。"""
import unittest
from app.services.sample_storage_cert_qc import (
SAMPLE_TYPE_KEY_PERINATAL_HSC,
SAMPLE_TYPE_KEY_UC_MSC,
empty_detail,
normalize_payload,
resolve_sample_type_key,
supports_storage_cert_qc,
)
class TestResolveSampleTypeKey(unittest.TestCase):
def test_hsc(self):
self.assertEqual(resolve_sample_type_key("围产期造血干细胞"), SAMPLE_TYPE_KEY_PERINATAL_HSC)
def test_msc(self):
self.assertEqual(resolve_sample_type_key("脐带间充质干细胞"), SAMPLE_TYPE_KEY_UC_MSC)
def test_other(self):
self.assertIsNone(resolve_sample_type_key("胎盘亚全能干细胞"))
self.assertIsNone(resolve_sample_type_key(""))
def test_supports(self):
self.assertTrue(supports_storage_cert_qc("围产期造血干细胞"))
self.assertFalse(supports_storage_cert_qc("其他"))
class TestNormalizePayload(unittest.TestCase):
def test_rejects_non_object(self):
with self.assertRaises(ValueError):
normalize_payload([])
with self.assertRaises(ValueError):
normalize_payload("x")
def test_keeps_dict_stringifies_keys(self):
out = normalize_payload({"viability_pct": 90.4, 1: "bad"})
self.assertEqual(out["viability_pct"], 90.4)
self.assertNotIn(1, out)
class TestEmptyDetail(unittest.TestCase):
def test_shape(self):
d = empty_detail(
sample_code="B1",
protocol_code="P1",
mother_name="张三",
sample_type_name="围产期造血干细胞",
sample_type_key="perinatal_hsc",
)
self.assertFalse(d["filledFlag"])
self.assertEqual(d["payload"], {})
self.assertEqual(d["sampleCode"], "B1")
self.assertEqual(d["labFlowCode"], "B1")
if __name__ == "__main__":
unittest.main()
cd backend && source venv/bin/activate && pytest tests/test_sample_storage_cert_qc.py -v
Expected: FAIL import / not found
"""样本储存证质检信息 — 类型键与 payload 工具。"""
from __future__ import annotations
import json
from typing import Any, Dict, Optional
SAMPLE_TYPE_NAME_PERINATAL_HSC = "围产期造血干细胞"
SAMPLE_TYPE_NAME_UC_MSC = "脐带间充质干细胞"
SAMPLE_TYPE_KEY_PERINATAL_HSC = "perinatal_hsc"
SAMPLE_TYPE_KEY_UC_MSC = "uc_msc"
_NAME_TO_KEY = {
SAMPLE_TYPE_NAME_PERINATAL_HSC: SAMPLE_TYPE_KEY_PERINATAL_HSC,
SAMPLE_TYPE_NAME_UC_MSC: SAMPLE_TYPE_KEY_UC_MSC,
}
def resolve_sample_type_key(sample_type_name: Optional[str]) -> Optional[str]:
name = (sample_type_name or "").strip()
return _NAME_TO_KEY.get(name)
def supports_storage_cert_qc(sample_type_name: Optional[str]) -> bool:
return resolve_sample_type_key(sample_type_name) is not None
def normalize_payload(payload: Any) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise ValueError("payload 必须为对象")
out: Dict[str, Any] = {}
for k, v in payload.items():
if not isinstance(k, str):
continue
key = k.strip()
if not key:
continue
out[key] = v
return out
def dumps_payload(payload: Dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def loads_payload(raw: Optional[str]) -> Dict[str, Any]:
if not raw:
return {}
try:
data = json.loads(raw)
except (TypeError, ValueError):
return {}
if not isinstance(data, dict):
return {}
return data
def empty_detail(
*,
sample_code: str,
protocol_code: str = "",
mother_name: str = "",
sample_type_name: str = "",
sample_type_key: Optional[str] = None,
collection_protocol_id: str = "",
qc_work_order_id: str = "",
) -> Dict[str, Any]:
return {
"sampleCode": sample_code,
"labFlowCode": sample_code,
"protocolCode": protocol_code or "",
"motherName": mother_name or "",
"sampleTypeName": sample_type_name or "",
"sampleTypeKey": sample_type_key or resolve_sample_type_key(sample_type_name),
"collectionProtocolId": collection_protocol_id or "",
"qcWorkOrderId": qc_work_order_id or "",
"filledFlag": False,
"payload": {},
}
cd backend && source venv/bin/activate && pytest tests/test_sample_storage_cert_qc.py -v
Expected: PASS
git add backend/app/services/sample_storage_cert_qc.py backend/tests/test_sample_storage_cert_qc.py
git commit -m "$(cat <<'EOF'
feat(backend): add storage-cert QC type-key and payload helpers.
EOF
)"
Files:
backend/app/crud/sample_storage_cert_qc.pyModify: backend/tests/test_sample_storage_cert_qc.py(可增 mock/纯组装测;若无 DB fixture,优先测 _row_to_dict 用 SimpleNamespace)
[ ] Step 1: 实现 CRUD
"""样本储存证质检信息 CRUD。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import (
BizCellSampleType,
BizCollectionProtocol,
BizProtocolPartyCustomer,
BizProtocolSample,
BizSampleStorageCertQc,
)
from app.services.sample_storage_cert_qc import (
dumps_payload,
empty_detail,
loads_payload,
normalize_payload,
resolve_sample_type_key,
)
def _row_to_dict(
row: BizSampleStorageCertQc,
*,
protocol_code: str = "",
mother_name: str = "",
sample_type_name: str = "",
) -> Dict[str, Any]:
return {
"id": row.id,
"sampleCode": row.sample_code or "",
"labFlowCode": row.sample_code or "",
"protocolCode": protocol_code or "",
"motherName": mother_name or "",
"sampleTypeName": sample_type_name or "",
"sampleTypeKey": row.sample_type_key,
"collectionProtocolId": row.collection_protocol_id or "",
"qcWorkOrderId": row.qc_work_order_id or "",
"filledFlag": str(row.filled_flag or "0") == "1",
"payload": loads_payload(row.payload),
"createTime": row.create_time.isoformat(sep=" ", timespec="seconds") if row.create_time else "",
"updateTime": row.update_time.isoformat(sep=" ", timespec="seconds") if row.update_time else "",
}
def _load_sample_context(db: Session, tenant_no: str, sample_code: str):
code = (sample_code or "").strip()
if not code:
return None
sample = (
db.query(BizProtocolSample)
.filter(
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.sample_code == code,
BizProtocolSample.del_flag == 0,
)
.first()
)
if not sample:
return None
type_name = ""
if sample.sample_type_id:
st = (
db.query(BizCellSampleType)
.filter(
BizCellSampleType.id == sample.sample_type_id,
BizCellSampleType.del_flag == 0,
)
.first()
)
if st:
type_name = st.type_name or ""
protocol_code = ""
mother_name = ""
if sample.collection_protocol_id:
proto = (
db.query(BizCollectionProtocol)
.filter(
BizCollectionProtocol.id == sample.collection_protocol_id,
BizCollectionProtocol.tenant_no == tenant_no,
BizCollectionProtocol.del_flag == 0,
)
.first()
)
if proto:
protocol_code = proto.protocol_no or ""
pc = (
db.query(BizProtocolPartyCustomer)
.filter(
BizProtocolPartyCustomer.collection_protocol_id == sample.collection_protocol_id,
BizProtocolPartyCustomer.tenant_no == tenant_no,
BizProtocolPartyCustomer.del_flag == 0,
)
.first()
)
if pc:
mother_name = pc.mother_name or ""
return sample, type_name, protocol_code, mother_name
def get_detail(db: Session, tenant_no: str, sample_code: str) -> Optional[Dict[str, Any]]:
"""样本不存在返回 None;存在但未填返回 empty_detail。"""
ctx = _load_sample_context(db, tenant_no, sample_code)
if not ctx:
return None
sample, type_name, protocol_code, mother_name = ctx
key = resolve_sample_type_key(type_name)
row = (
db.query(BizSampleStorageCertQc)
.filter(
BizSampleStorageCertQc.tenant_no == tenant_no,
BizSampleStorageCertQc.sample_code == sample.sample_code,
BizSampleStorageCertQc.del_flag == 0,
)
.first()
)
if not row:
return empty_detail(
sample_code=sample.sample_code or "",
protocol_code=protocol_code,
mother_name=mother_name,
sample_type_name=type_name,
sample_type_key=key,
collection_protocol_id=sample.collection_protocol_id or "",
)
return _row_to_dict(
row,
protocol_code=protocol_code,
mother_name=mother_name,
sample_type_name=type_name,
)
def upsert(
db: Session,
*,
tenant_no: str,
user_id: str,
sample_code: str,
payload: Any,
qc_work_order_id: Optional[str] = None,
) -> Dict[str, Any]:
ctx = _load_sample_context(db, tenant_no, sample_code)
if not ctx:
raise ValueError("样本不存在")
sample, type_name, protocol_code, mother_name = ctx
key = resolve_sample_type_key(type_name)
if not key:
raise ValueError("该样本类型不支持填写储存证质检信息")
clean = normalize_payload(payload)
now = datetime.now()
row = (
db.query(BizSampleStorageCertQc)
.filter(
BizSampleStorageCertQc.tenant_no == tenant_no,
BizSampleStorageCertQc.sample_code == sample.sample_code,
BizSampleStorageCertQc.del_flag == 0,
)
.first()
)
if not row:
row = BizSampleStorageCertQc(
id=generate_uuid(),
sample_code=sample.sample_code,
collection_protocol_id=sample.collection_protocol_id,
qc_work_order_id=(qc_work_order_id or None),
sample_type_key=key,
payload=dumps_payload(clean),
filled_flag="1",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
else:
row.collection_protocol_id = sample.collection_protocol_id
row.sample_type_key = key
row.payload = dumps_payload(clean)
row.filled_flag = "1"
if qc_work_order_id:
row.qc_work_order_id = qc_work_order_id
row.update_by = user_id
row.update_time = now
db.commit()
db.refresh(row)
return _row_to_dict(
row,
protocol_code=protocol_code,
mother_name=mother_name,
sample_type_name=type_name,
)
协议编码取 BizCollectionProtocol.protocol_no(与现网列表 protocolNo 一致)。
_row_to_dict 单测(SimpleNamespace)在 test_sample_storage_cert_qc.py 增加:
from types import SimpleNamespace
from app.crud.sample_storage_cert_qc import _row_to_dict
class TestRowToDict(unittest.TestCase):
def test_filled(self):
row = SimpleNamespace(
id="1",
sample_code="B1",
sample_type_key="perinatal_hsc",
collection_protocol_id="P",
qc_work_order_id="W",
filled_flag="1",
payload='{"viability_pct":"90"}',
create_time=None,
update_time=None,
)
d = _row_to_dict(row, protocol_code="C1", mother_name="李", sample_type_name="围产期造血干细胞")
self.assertTrue(d["filledFlag"])
self.assertEqual(d["payload"]["viability_pct"], "90")
cd backend && source venv/bin/activate && pytest tests/test_sample_storage_cert_qc.py -v
git add backend/app/crud/sample_storage_cert_qc.py backend/tests/test_sample_storage_cert_qc.py
git commit -m "$(cat <<'EOF'
feat(backend): add sample storage-cert QC get and upsert CRUD.
EOF
)"
Files:
backend/app/api/sample_storage_cert_qc.pybackend/app/api/__init__.pyModify: backend/app/main.py
[ ] Step 1: 写 API 模块
"""样本储存证质检信息 API"""
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.menu_guard import require_any_menu_code
from app.core.response import error_response, success_response
from app.crud import sample_storage_cert_qc as svc
from app.models.models import SysUser
router = APIRouter(prefix="/web/admin", tags=["储存证质检信息"])
READ_MENUS = (
"biz:qcWorkOrder:list",
"biz:myQcTask:list",
"cell-storage:sample-info:list",
)
WRITE_MENUS = (
"biz:qcWorkOrder:list",
"biz:myQcTask:list",
)
def _get_tenant_no(db: Session, user: dict) -> str:
user_id = user.get("user_id")
if not user_id:
raise PermissionError("用户身份信息缺失,无法确定租户")
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
if not sys_user or not sys_user.tenant_no:
raise PermissionError("用户租户信息缺失")
return sys_user.tenant_no
def _get_current_user_id(user: dict) -> str:
user_id = user.get("user_id")
if not user_id:
raise PermissionError("用户身份信息缺失")
return str(user_id)
class UpsertBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
payload: Dict[str, Any] = Field(default_factory=dict)
qc_work_order_id: Optional[str] = Field(None, alias="qcWorkOrderId")
@router.get("/sample-storage-cert-qc/{sample_code}")
async def get_sample_storage_cert_qc(
sample_code: str,
user: dict = Depends(require_any_menu_code(*READ_MENUS)),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
detail = svc.get_detail(db, tenant_no, sample_code)
if detail is None:
return error_response(404, "样本不存在")
return success_response(detail)
@router.put("/sample-storage-cert-qc/{sample_code}")
async def put_sample_storage_cert_qc(
sample_code: str,
body: UpsertBody,
user: dict = Depends(require_any_menu_code(*WRITE_MENUS)),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
try:
detail = svc.upsert(
db,
tenant_no=tenant_no,
user_id=user_id,
sample_code=sample_code,
payload=body.payload,
qc_work_order_id=body.qc_work_order_id,
)
return success_response(detail, message="保存成功")
except ValueError as e:
return error_response(400, str(e))
backend/app/api/__init__.py:增加
from app.api.sample_storage_cert_qc import router as sample_storage_cert_qc_router
并加入 __all__。
backend/app/main.py:在其它 admin router 旁增加
from app.api import sample_storage_cert_qc_router # 或与现有 import 风格一致
app.include_router(sample_storage_cert_qc_router, prefix=settings.API_V1_PREFIX)
(按 main.py 现有从 app.api 批量 import 的写法对齐。)
对支持类型样本:GET 未填 → filledFlag=false;PUT 部分 payload → 再 GET 可见;非支持类型 PUT → 400。
无环境则依赖单测 + 代码审阅。
git add backend/app/api/sample_storage_cert_qc.py backend/app/api/__init__.py backend/app/main.py
git commit -m "$(cat <<'EOF'
feat(api): expose GET/PUT sample-storage-cert-qc by sample code.
EOF
)"
Files:
web-admin/src/utils/storage-cert-qc-fields.tsCreate: web-admin/src/api/sample-storage-cert-qc.ts
[ ] Step 1: 字段元数据
对齐 design §5。结构示意:
import {
SAMPLE_TYPE_PERINATAL_HSC,
SAMPLE_TYPE_UC_MSC
} from '@/utils/sample-production-reception-fields'
export type StorageCertQcTypeKey = 'perinatal_hsc' | 'uc_msc'
export type StorageCertQcControl = 'text' | 'textarea' | 'select_yny'
export interface StorageCertQcField {
key: string
label: string
control?: StorageCertQcControl
span?: 1 | 2 | 3
}
export interface StorageCertQcGroup {
title: string
variant?: 'primary' | 'alt' | 'meta'
fields: StorageCertQcField[]
}
export function resolveStorageCertQcTypeKey(
sampleTypeName?: string | null
): StorageCertQcTypeKey | null {
const name = String(sampleTypeName || '').trim()
if (name === SAMPLE_TYPE_PERINATAL_HSC) return 'perinatal_hsc'
if (name === SAMPLE_TYPE_UC_MSC) return 'uc_msc'
return null
}
export function supportsStorageCertQc(sampleTypeName?: string | null): boolean {
return resolveStorageCertQcTypeKey(sampleTypeName) != null
}
const MOTHER_BLOOD: StorageCertQcField[] = [
{ key: 'mother_hbsag', label: '乙型肝炎病毒表面抗原(HBsAg)', control: 'select_yny' },
{ key: 'mother_hbv_dna', label: '乙型肝炎病毒核酸(HBV DNA)', control: 'select_yny' },
{ key: 'mother_anti_hcv', label: '丙型肝炎病毒抗体(抗HCV)', control: 'select_yny' },
{ key: 'mother_hcv_rna', label: '丙型肝炎病毒核酸(HCV RNA)', control: 'select_yny' },
{ key: 'mother_anti_tp', label: '梅毒螺旋体特异性抗体(抗TP)', control: 'select_yny' },
{ key: 'mother_cmv_igm', label: '巨细胞病毒 IgM 抗体 (CMV IgM)', control: 'select_yny' },
{ key: 'mother_anti_hiv', label: '人类免疫缺陷病毒抗体(抗HIV-1/2)', control: 'select_yny' },
{ key: 'mother_hiv1_rna', label: '人类免疫缺陷病毒核酸(1型) (HIV-1RNA)', control: 'select_yny' }
]
export const STORAGE_CERT_QC_GROUPS: Record<StorageCertQcTypeKey, StorageCertQcGroup[]> = {
perinatal_hsc: [
{
title: '采集与运输',
variant: 'alt',
fields: [
{ key: 'collection_time', label: '造血干细胞采集时间' },
{ key: 'transport_temp_c', label: '运输温度 /℃' }
]
},
{
title: '造血干细胞检测报告',
variant: 'primary',
fields: [
{ key: 'receive_time', label: '造血干细胞接收时间' },
{ key: 'process_time', label: '处理时间' },
{ key: 'cryo_time', label: '冻存时间' },
{ key: 'weight_g', label: '造血干细胞重量/g' },
{ key: 'cryo_volume_ml', label: '造血干细胞冻存体积/mL' },
{ key: 'tnc_1e8', label: '有核细胞数 TNC / *10E8' },
{ key: 'cd34_count_1e6', label: 'CD34+ 细胞计数 / *10E6' },
{ key: 'cd34_cd45dim_1e5', label: 'CD34+ CD45dim 细胞计数 / *10E5' },
{ key: 'viability_pct', label: '细胞活性/%' },
{ key: 'bacteria_test', label: '细菌检测', control: 'select_yny' },
{ key: 'hbsag', label: '乙型肝炎病毒表面抗原(HBsAg)', control: 'select_yny' },
{ key: 'anti_hcv', label: '丙型肝炎病毒抗体(抗HCV)', control: 'select_yny' },
{ key: 'anti_tp', label: '梅毒螺旋体特异性抗体(抗TP)', control: 'select_yny' },
{ key: 'anti_hiv', label: '人类免疫缺陷病毒抗体(抗HIV-1/2)', control: 'select_yny' },
{ key: 'cmv_igm', label: '巨细胞病毒 IgM 抗体 (CMV-IgM)', control: 'select_yny' }
]
},
{ title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD },
{
title: '储存证收尾信息',
variant: 'meta',
fields: [
{ key: 'hla_typing', label: 'HLA 分型', control: 'textarea', span: 3 },
{ key: 'storage_date', label: '储存日期' },
{ key: 'storage_years', label: '储存年限' },
{ key: 'result_comment', label: '检测结果评论' },
{ key: 'report_issue_date', label: '报告发出日期' }
]
}
],
uc_msc: [
{
title: '采集与运输 / 邮寄',
variant: 'alt',
fields: [
{ key: 'mail_flag', label: '是否邮寄' },
{ key: 'collection_time', label: '脐带采集时间' },
{ key: 'transport_temp_c', label: '运输温度 /℃' }
]
},
{
title: '脐带间充质干细胞检测报告',
variant: 'primary',
fields: [
{ key: 'receive_time', label: '脐带接收时间' },
{ key: 'process_time', label: '处理时间' },
{ key: 'cryo_time', label: '冻存时间' },
{ key: 'storage_tube_count', label: '储存总量(管)' },
{ key: 'available_cell_type', label: '可提供细胞类型' },
{ key: 'available_cell_count_1e7', label: '可提供细胞数量 / *10E7' },
{ key: 'available_viability_pct', label: '可提供细胞存活率 %' },
{ key: 'endotoxin_test', label: '内毒素检测', control: 'select_yny' },
{ key: 'bacteria_test', label: '细菌检测', control: 'select_yny' },
{ key: 'fungus_test', label: '真菌检测', control: 'select_yny' },
{ key: 'mycoplasma_test', label: '支原体检测', control: 'select_yny' },
{ key: 'available_cell_morphology', label: '可提供细胞形态' },
{
key: 'available_immunophenotype',
label: '可提供细胞免疫表型特征',
span: 2
}
]
},
{ title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD },
{
title: '储存证收尾信息',
variant: 'meta',
fields: [
{ key: 'result_judgment', label: '检测结果评判' },
{ key: 'report_issue_date', label: '报告发出日期' },
{ key: 'storage_date_p2', label: '储存日期 (P2冻存日期)' },
{ key: 'freeze_method', label: '冷冻方式' },
{ key: 'storage_temp', label: '储存温度' },
{ key: 'storage_years', label: '储存年限' }
]
}
]
}
export const YNY_OPTIONS = [
{ label: '阴', value: '阴' },
{ label: '阳', value: '阳' },
{ label: '/', value: '/' }
]
import request from '@/utils/http'
export type SampleStorageCertQcDetail = {
sampleCode: string
labFlowCode: string
protocolCode: string
motherName: string
sampleTypeName: string
sampleTypeKey: string | null
collectionProtocolId?: string
qcWorkOrderId?: string
filledFlag: boolean
payload: Record<string, unknown>
}
export function fetchSampleStorageCertQc(sampleCode: string) {
return request.get<SampleStorageCertQcDetail>({
url: `/web/admin/sample-storage-cert-qc/${encodeURIComponent(sampleCode)}`
})
}
export function saveSampleStorageCertQc(
sampleCode: string,
data: { payload: Record<string, unknown>; qcWorkOrderId?: string }
) {
return request.put<SampleStorageCertQcDetail>({
url: `/web/admin/sample-storage-cert-qc/${encodeURIComponent(sampleCode)}`,
data
})
}
(若项目 request.get 泛型写法不同,对齐 sample-info-handle.ts / qc-work-order.ts。)
git add web-admin/src/utils/storage-cert-qc-fields.ts web-admin/src/api/sample-storage-cert-qc.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add storage-cert QC field metadata and API client.
EOF
)"
Files:
Create: web-admin/src/components/business/storage-cert-qc-dialog/index.vue
[ ] Step 1: 实现弹窗
Props:
visible / v-model:visiblemode: 'edit' | 'view'sampleCode: stringsampleTypeName?: string(打开前可用于提前判断;仍以 GET 返回为准)qcWorkOrderId?: string(edit 时可选传入)行为:
visible=true 且有 sampleCode → fetchSampleStorageCertQcmode=view 且 !filledFlag → 身份区 + 空态「质检尚未填写」mode=edit → 按 sampleTypeKey 渲染 STORAGE_CERT_QC_GROUPS;el-form label-position="top";三列 form-grid-3(若全局无则用 inline grid / form-grid-2 + span)select_yny → el-select + YNY_OPTIONSsaveSampleStorageCertQc,成功 ElMessage.success、emit('saved')、关弹窗dialog-form-layout、append-to-body、modal-class="protocol-dialog-mask"、align-center视觉分组可参考 Demo;样式用 scoped + Fresh Mint token,勿引入小程序色。
git add web-admin/src/components/business/storage-cert-qc-dialog/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add shared storage-cert QC fill/view dialog.
EOF
)"
Files:
Modify: web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderSingleCard.vue
[ ] Step 1: 显示条件与按钮
在 showWorkOrderOps / showWorkOrderViewOnly 两个操作区模板中,于「特殊样本」附近增加:
<el-button
v-if="showStorageCertQcButton"
class="btn-op-item"
@click="openStorageCertQcDialog"
>
<el-icon><Document /></el-icon>填写储存证质检信息
</el-button>
(Document 从 @element-plus/icons-vue 引入;manage/mine 均显示,不额外限制执行人。)
import { supportsStorageCertQc } from '@/utils/storage-cert-qc-fields'
import StorageCertQcDialog from '@/components/business/storage-cert-qc-dialog/index.vue'
const showStorageCertQcButton = computed(() =>
supportsStorageCertQc(String(props.row?.sampleTypeName || ''))
)
const storageCertQcVisible = ref(false)
function openStorageCertQcDialog() {
storageCertQcVisible.value = true
}
模板底部挂载:
<StorageCertQcDialog
v-model:visible="storageCertQcVisible"
mode="edit"
:sample-code="String(row.sampleCode || '')"
:sample-type-name="String(row.sampleTypeName || '')"
:qc-work-order-id="String(row.id || '')"
/>
若 row.sampleTypeName 在某些布局为空:从父级 SampleGroup / protocol gate 已有字段补传 props(优先修卡片数据源,保持与列表 sampleTypeName 一致)。
细胞存储质检 Tab:造血 / 间充质卡片有按钮;其它类型无。保存后再次打开有数据。
git add web-admin/src/views/quality-inspection/qc-work-order/QcWorkOrderSingleCard.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add storage-cert QC fill entry on QC work-order cards.
EOF
)"
Files:
Modify: web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue
[ ] Step 1: 操作列
将操作列 min-width 提到 ≥ 260(规范)。细胞存储 Tab 行内:
<el-button
v-if="isCellStorage && supportsStorageCertQc(row.sampleTypeName)"
class="btn-op-item"
@click="openStorageCertQcView(row)"
>
<el-icon><Document /></el-icon>查看储存证的质检信息
</el-button>
始终可点(不按 filled 禁用)。挂载 view 模式弹窗;openStorageCertQcView 设 sampleCode 并打开。
科研捐赠 Tab 不显示该按钮。
未填 →「质检尚未填写」;质检已填 → 只读字段一致。
git add web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add storage-cert QC view on cell-storage sample-info.
EOF
)"
./scripts/update-graph.sh all
(若仅后端或仅前端有改,可分别 backend / frontend。)
| Spec 要求 | Task |
|---|---|
| 表 + JSON payload | 1, 3 |
| 类型键映射 | 2 |
| GET 空记录不 404 | 3, 4 |
| PUT upsert / 部分保存 / 可覆盖 | 3, 4 |
| 仅两类型可写 | 2, 3, 4 |
| 工单卡片填写 | 7 |
| 样本信息查看 + 空态文案 | 6, 8 |
| 手填、身份只读 | 6 |
| 非目标未做 | — |
git add backend/graphify-out web-admin/graphify-out
git commit -m "$(cat <<'EOF'
chore: refresh graphify after storage-cert QC info.
EOF
)"
protocol_no。sampleTypeKey / filledFlag / payload / labFlowCode 前后端一致;菜单码与现网一致。Plan complete and saved to docs/superpowers/plans/2026-10-09-storage-cert-qc-info.md. Two execution options:
Which approach?