Files
zkxw/docs/superpowers/plans/2026-05-24-cell-product-source-chain.md
T
2026-05-24 16:58:51 +08:00

26 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: 在细胞产品库列表页展示来源信息(来源类型、协议号、工单号、任务号),并在查看弹窗中展示完整的双向溯源链路图(祖先 + 子代)。

Architecture: 后端在现有列表 API 中批量富化来源字段,新增 /cell-products/{id}/chain 链路 API。前端列表新增 4 列 + 1 个筛选,查看弹窗新增 SourceChainGraph.vue 组件(CSS 绘制横向 DAG)。

Tech Stack: FastAPI + SQLAlchemy + Pydantic v2(后端),Vue 3 + Element Plus + SCSS(前端)

Design Spec: docs/superpowers/specs/2026-05-24-cell-product-source-chain-design.md Demo: docs/demo-source-chain-graph.html


File Structure

Action File Responsibility
Modify backend/app/crud/cell_product.py 来源富化查询 + 链路递归函数
Modify backend/app/schemas/cell_product.py CellProductListQuery 新增 source_type
Modify backend/app/api/production.py 列表路由传 source_type + 新增 chain 路由
Modify web-admin/src/api/cell-product.ts 新增 fetchCellProductChain + list 参数扩展
Modify web-admin/src/views/production/cell-product/index.vue 列表新增列/筛选 + 查看弹窗集成链路图
Create web-admin/src/views/production/cell-product/SourceChainGraph.vue 链路图组件

Task 1: 后端 — Schema 新增 source_type 筛选参数

Files:

  • Modify: backend/app/schemas/cell_product.py:127-137

  • Step 1: 在 CellProductListQuery 中新增 source_type 字段

在 backend/app/schemas/cell_product.py 的 CellProductListQuery 类末尾追加:

    source_type: Optional[str] = Field(None, alias="sourceType")

完整的 CellProductListQuery 应变为:

class CellProductListQuery(BaseModel):
    model_config = ConfigDict(populate_by_name=True)

    page: int = 1
    page_size: int = Field(20, alias="pageSize")
    keyword: Optional[str] = None
    product_code: Optional[str] = Field(None, alias="productCode")
    product_name: Optional[str] = Field(None, alias="productName")
    product_type_id: Optional[str] = Field(None, alias="productTypeId")
    product_status: Optional[str] = Field(None, alias="productStatus")
    passage: Optional[str] = None
    source_type: Optional[str] = Field(None, alias="sourceType")
  • Step 2: 验证无语法错误

Run: cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.schemas.cell_product import CellProductListQuery; print('OK')" Expected: OK


Task 2: 后端 — 列表 API 增加 source_type 筛选

Files:

  • Modify: backend/app/api/production.py:1941-1966

  • Modify: backend/app/crud/cell_product.py:150-208

  • Step 1: API 路由层新增 sourceType 参数

在 backend/app/api/production.py 的 list_cell_products 函数中:

  1. 在参数列表 passage 之后新增:sourceType: Optional[str] = None,
  2. 在 cell_product_svc.list_cell_products(...) 调用中新增:source_type=sourceType,

修改后的函数签名和调用:

@router.get("/cell-products")
async def list_cell_products(
    page: int = Query(1, ge=1),
    pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
    keyword: Optional[str] = None,
    productCode: Optional[str] = None,
    productName: Optional[str] = None,
    productTypeId: Optional[str] = None,
    productStatus: Optional[str] = None,
    passage: Optional[str] = None,
    sourceType: Optional[str] = None,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    tenant_no = _get_tenant_no(db, user)
    result = cell_product_svc.list_cell_products(
        db, tenant_no=tenant_no, page=page, page_size=pageSize,
        keyword=keyword,
        product_code=productCode, product_name=productName,
        product_type_id=productTypeId, product_status=productStatus,
        passage=passage, source_type=sourceType,
        user_info=user,
    )
    items = result.get("items", [])
    total = result.get("total", 0)
    tp = cell_product_svc.total_pages(total, pageSize)
    return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
  • Step 2: CRUD 层 list_cell_products 新增 source_type 过滤

在 backend/app/crud/cell_product.py 的 list_cell_products 函数中:

  1. 在参数列表 passage 之后新增:source_type: Optional[str] = None,
  2. 在 _filtered() 内部、if passage: 块之后新增:
        if source_type:
            q0 = q0.filter(BizCellProduct.source_type == source_type)
  • Step 3: 验证无语法错误

Run: cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.crud.cell_product import list_cell_products; print('OK')" Expected: OK


Task 3: 后端 — 列表富化查询新增来源字段

Files:

  • Modify: backend/app/crud/cell_product.py:72-142(_enrich_product_dicts 函数)

  • Step 1: 在 _enrich_product_dicts 中新增来源信息批量富化

在 _enrich_product_dicts 函数中,在 slot_map 查询之后、for item in items: 循环之前,新增以下代码块:

    # --- 来源信息富化 ---
    from app.models.models import BizProductionTask, BizProductionWorkOrder

    task_ids = {i.get("productionTaskId") for i in items if i.get("productionTaskId")}
    task_map: Dict[str, Any] = {}
    task_to_wo: Dict[str, str] = {}
    wo_map: Dict[str, Any] = {}

    if task_ids:
        tasks = (
            db.query(BizProductionTask)
            .filter(
                BizProductionTask.id.in_(task_ids),
                BizProductionTask.del_flag == 0,
            )
            .all()
        )
        for t in tasks:
            task_map[t.id] = t
            if t.work_order_id:
                task_to_wo[t.id] = t.work_order_id

        wo_ids = set(task_to_wo.values())
        if wo_ids:
            wos = (
                db.query(BizProductionWorkOrder)
                .filter(
                    BizProductionWorkOrder.id.in_(wo_ids),
                    BizProductionWorkOrder.del_flag == 0,
                )
                .all()
            )
            wo_map = {w.id: w for w in wos}

    source_type_labels = {"production": "生产入库", "manual": "手动录入"}

然后在 for item in items: 循环内,在 item["storageSlotName"] = ... 之后追加:

        # 来源信息
        src_type = item.get("sourceType", "")
        item["sourceTypeLabel"] = source_type_labels.get(src_type, src_type or "")
        tid = item.get("productionTaskId") or ""
        if tid and tid in task_map:
            t_obj = task_map[tid]
            item["sourceTaskNo"] = t_obj.task_no or ""
            wo_id = task_to_wo.get(tid)
            if wo_id and wo_id in wo_map:
                wo_obj = wo_map[wo_id]
                item["sourceWorkOrderNo"] = wo_obj.work_order_no or ""
                item["sourceProtocolNo"] = wo_obj.protocol_no or ""
                item["sourceReceptionSource"] = wo_obj.reception_source or ""
            else:
                item["sourceWorkOrderNo"] = ""
                item["sourceProtocolNo"] = ""
                item["sourceReceptionSource"] = ""
        else:
            item["sourceTaskNo"] = ""
            item["sourceWorkOrderNo"] = ""
            item["sourceProtocolNo"] = ""
            item["sourceReceptionSource"] = ""

注意:需要确保 _row_to_dict 不需要修改 — 它已经返回 sourceType 和 productionTaskId,新的富化字段是在 _enrich_product_dicts 中动态追加的。

  • Step 2: 验证无语法错误

Run: cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c "from app.crud.cell_product import _enrich_product_dicts; print('OK')" Expected: OK

  • Step 3: 启动后端验证列表 API

Run: cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c " from app.core.database import SessionLocal from app.crud.cell_product import list_cell_products db = SessionLocal() try: result = list_cell_products(db, tenant_no='zkxw', page=1, page_size=3) for item in result.get('items', []): print(f\"{item.get('productCode')} | src={item.get('sourceTypeLabel')} | proto={item.get('sourceProtocolNo')} | wo={item.get('sourceWorkOrderNo')} | task={item.get('sourceTaskNo')}\") finally: db.close() " Expected: 每行输出产品编码及来源字段(可能为空字符串)


Task 4: 后端 — 新增链路 API

Files:

  • Modify: backend/app/crud/cell_product.py(文件末尾追加)

  • Modify: backend/app/api/production.py(在 GET /cell-products/{product_id} 路由之后追加)

  • Step 1: 在 cell_product.py 末尾新增链路递归查询函数

在 backend/app/crud/cell_product.py 文件末尾追加:

# ---------------------------------------------------------------------------
# 来源链路 API
# ---------------------------------------------------------------------------

_MAX_CHAIN_DEPTH = 10

_SOURCE_TYPE_LABELS = {"production": "生产入库", "manual": "手动录入"}


def _product_node(obj: BizCellProduct, is_current: bool = False) -> Dict[str, Any]:
    return {
        "id": obj.id,
        "productCode": obj.product_code or "",
        "productName": obj.product_name or "",
        "nodeType": "cell_product",
        "productStatus": obj.product_status or "",
        "passage": obj.passage or "",
        "isCurrent": is_current,
    }


def _build_ancestor_chain(
    db: Session, product: BizCellProduct, depth: int
) -> List[Dict[str, Any]]:
    if depth >= _MAX_CHAIN_DEPTH:
        return []
    task_id = product.production_task_id
    if not task_id:
        return []

    from app.models.models import BizProductionTask, BizProductionWorkOrder
    from app.models.models import BizProductionTaskInput

    task = (
        db.query(BizProductionTask)
        .filter(BizProductionTask.id == task_id, BizProductionTask.del_flag == 0)
        .first()
    )
    if not task:
        return []

    wo = None
    edge: Dict[str, Any] = {"workOrderNo": "", "workOrderType": "", "taskNo": task.task_no or "", "taskName": task.task_name or ""}
    if task.work_order_id:
        wo = (
            db.query(BizProductionWorkOrder)
            .filter(BizProductionWorkOrder.id == task.work_order_id, BizProductionWorkOrder.del_flag == 0)
            .first()
        )
        if wo:
            edge["workOrderNo"] = wo.work_order_no or ""
            edge["workOrderType"] = wo.reception_source or ""

    # 检查投入物中是否有父细胞产品
    parent_input = (
        db.query(BizProductionTaskInput)
        .filter(
            BizProductionTaskInput.task_id == task_id,
            BizProductionTaskInput.input_type == "cell_product",
            BizProductionTaskInput.del_flag == 0,
        )
        .first()
    )
    if parent_input and parent_input.input_id:
        parent_product = (
            db.query(BizCellProduct)
            .filter(BizCellProduct.id == parent_input.input_id, BizCellProduct.del_flag == 0)
            .first()
        )
        if parent_product:
            return [{"node": _product_node(parent_product), "edge": edge, "parent": _build_ancestor_chain(db, parent_product, depth + 1)}]

    # 检查投入物中是否有样本(终止节点)
    sample_input = (
        db.query(BizProductionTaskInput)
        .filter(
            BizProductionTaskInput.task_id == task_id,
            BizProductionTaskInput.input_type == "sample",
            BizProductionTaskInput.del_flag == 0,
        )
        .first()
    )
    if sample_input and sample_input.input_id:
        from app.models.models import BizProtocolSample
        sample = (
            db.query(BizProtocolSample)
            .filter(BizProtocolSample.id == sample_input.input_id, BizProtocolSample.del_flag == 0)
            .first()
        )
        if sample:
            sample_node = {
                "id": sample.id,
                "productCode": sample.sample_code or "",
                "productName": "",
                "nodeType": "sample",
                "sampleCode": sample.sample_code or "",
                "sampleType": sample.sample_type or "",
                "isCurrent": False,
            }
            return [{"node": sample_node, "edge": edge, "parent": None}]

    return []


def _build_descendant_chain(
    db: Session, product_id: str, depth: int
) -> List[Dict[str, Any]]:
    if depth >= _MAX_CHAIN_DEPTH:
        return []

    from app.models.models import BizProductionTaskInput, BizProductionTaskOutput, BizProductionTask, BizProductionWorkOrder

    # 找到引用此产品作为投入物的所有任务
    input_rows = (
        db.query(BizProductionTaskInput)
        .filter(
            BizProductionTaskInput.input_type == "cell_product",
            BizProductionTaskInput.input_id == product_id,
            BizProductionTaskInput.del_flag == 0,
        )
        .all()
    )
    if not input_rows:
        return []

    task_ids = list({r.task_id for r in input_rows})
    tasks = (
        db.query(BizProductionTask)
        .filter(BizProductionTask.id.in_(task_ids), BizProductionTask.del_flag == 0)
        .all()
    )
    task_by_id = {t.id: t for t in tasks}

    wo_ids = list({t.work_order_id for t in tasks if t.work_order_id})
    wo_by_id: Dict[str, Any] = {}
    if wo_ids:
        wos = (
            db.query(BizProductionWorkOrder)
            .filter(BizProductionWorkOrder.id.in_(wo_ids), BizProductionWorkOrder.del_flag == 0)
            .all()
        )
        wo_by_id = {w.id: w for w in wos}

    # 对每个任务,找产出
    result: List[Dict[str, Any]] = []
    output_rows = (
        db.query(BizProductionTaskOutput)
        .filter(
            BizProductionTaskOutput.task_id.in_(task_ids),
            BizProductionTaskOutput.del_flag == 0,
        )
        .all()
    )

    child_product_ids = list({o.cell_product_id for o in output_rows if o.cell_product_id})
    child_products = {}
    if child_product_ids:
        cps = (
            db.query(BizCellProduct)
            .filter(BizCellProduct.id.in_(child_product_ids), BizCellProduct.del_flag == 0)
            .all()
        )
        child_products = {cp.id: cp for cp in cps}

    for o in output_rows:
        cp = child_products.get(o.cell_product_id)
        if not cp:
            continue
        t = task_by_id.get(o.task_id)
        edge: Dict[str, Any] = {"workOrderNo": "", "workOrderType": "", "taskNo": "", "taskName": ""}
        if t:
            edge["taskNo"] = t.task_no or ""
            edge["taskName"] = t.task_name or ""
            if t.work_order_id and t.work_order_id in wo_by_id:
                wo_obj = wo_by_id[t.work_order_id]
                edge["workOrderNo"] = wo_obj.work_order_no or ""
                edge["workOrderType"] = wo_obj.reception_source or ""

        result.append({
            "node": _product_node(cp),
            "edge": edge,
            "children": _build_descendant_chain(db, cp.id, depth + 1),
        })

    return result


def get_product_chain(db: Session, product_id: str, tenant_no: str) -> Optional[Dict[str, Any]]:
    product = get_cell_product(db, product_id, tenant_no)
    if not product:
        return None

    return {
        "current": _product_node(product, is_current=True),
        "ancestors": _build_ancestor_chain(db, product, 0),
        "descendants": _build_descendant_chain(db, product.id, 0),
    }
  • Step 2: 在 production.py 新增链路路由

在 backend/app/api/production.py 的 @router.get("/cell-products/{product_id}") 路由之后(约 line 1979 之后)追加新路由:

@router.get("/cell-products/{product_id}/chain")
async def get_cell_product_chain(
    product_id: str,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    tenant_no = _get_tenant_no(db, user)
    chain = cell_product_svc.get_product_chain(db, product_id, tenant_no)
    if not chain:
        raise HTTPException(status_code=404, detail="细胞产品不存在")
    return success_response(chain)

注意:此路由必须放在 @router.get("/cell-products/{product_id}") 之前或之后都可以,因为 FastAPI 按注册顺序匹配,/cell-products/next-code 已经在前面。但 /chain 是固定路径不会与 {product_id} 冲突 — 实际上 /cell-products/{product_id}/chain 和 /cell-products/{product_id} 是不同的路径模式,不存在冲突。

  • Step 3: 验证链路 API

Run: cd /Users/wuzilong/work/00code/zkxw/backend && source venv/bin/activate && python -c " from app.core.database import SessionLocal from app.crud.cell_product import get_product_chain db = SessionLocal() try: from app.crud.cell_product import list_cell_products prods = list_cell_products(db, tenant_no='zkxw', page=1, page_size=1) items = prods.get('items', []) if items: pid = items[0]['id'] chain = get_product_chain(db, pid, 'zkxw') if chain: cur = chain['current'] print(f'Current: {cur[\"productCode\"]}') print(f'Ancestors: {len(chain[\"ancestors\"])} levels') print(f'Descendants: {len(chain[\"descendants\"])} branches') else: print('No chain data') else: print('No products found') finally: db.close() " Expected: 输出当前产品的链路信息(祖先层数和子代分支数)


Task 5: 前端 — API 层扩展

Files:

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

  • Step 1: 新增 fetchCellProductChain 函数 + fetchCellProductList 参数扩展

将 web-admin/src/api/cell-product.ts 完整替换为:

import request from '@/utils/http'

export function fetchCellProductList(params?: {
  page?: number
  pageSize?: number
  keyword?: string
  productStatus?: string
  passage?: string
  sourceType?: string
}) {
  return request.get<{
    list: any[]
    total: number
    page: number
    pageSize: number
    totalPages: number
  }>({ url: '/web/admin/cell-products', params })
}

export function fetchCellProductDetail(id: string) {
  return request.get<any>({ url: `/web/admin/cell-products/${id}` })
}

export function fetchCreateCellProduct(data: Record<string, any>) {
  return request.post({ url: '/web/admin/cell-products', params: data })
}

export function fetchUpdateCellProduct(id: string, data: Record<string, any>) {
  return request.put({ url: `/web/admin/cell-products/${id}`, params: data })
}

export function fetchDeleteCellProduct(id: string) {
  return request.del({ url: `/web/admin/cell-products/${id}` })
}

export function fetchNextCellProductCode(count = 1) {
  return request.get<{ code: string; codes?: string[] }>({
    url: '/web/admin/cell-products/next-code',
    params: count > 1 ? { count } : undefined
  })
}

export function fetchCellProductChain(productId: string) {
  return request.get<any>({ url: `/web/admin/cell-products/${productId}/chain` })
}
  • Step 2: 验证无 TS 错误

Run: cd /Users/wuzilong/work/00code/zkxw/web-admin && npx vue-tsc --noEmit --pretty 2>&1 | head -20 Expected: 无新增错误(可能有预存的)


Task 6: 前端 — 列表页新增列和筛选

Files:

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

  • Step 1: 搜索表单新增来源类型筛选

在 index.vue 的搜索表单中找到 productStatus 的 el-select 之后,新增一个筛选下拉:

<el-select v-model="searchForm.sourceType" placeholder="来源类型" clearable style="width: 130px">
  <el-option label="生产入库" value="production" />
  <el-option label="手动录入" value="manual" />
</el-select>

同时在 searchForm 的响应式定义中增加 sourceType: '' 字段。

在调用 fetchCellProductList 时传入 sourceType: searchForm.sourceType || undefined。

  • Step 2: 表格新增 4 列

在 el-table 中 productStatus(状态)列之后,新增:

<el-table-column label="来源类型" prop="sourceTypeLabel" min-width="120">
  <template #default="{ row }">
    <el-tag v-if="row.sourceTypeLabel" :type="row.sourceType === 'production' ? 'success' : 'info'" size="small">
      {{ row.sourceTypeLabel }}
    </el-tag>
    <span v-else>—</span>
  </template>
</el-table-column>
<el-table-column label="来源协议号" prop="sourceProtocolNo" min-width="160" show-overflow-tooltip>
  <template #default="{ row }">{{ row.sourceProtocolNo || '—' }}</template>
</el-table-column>
<el-table-column label="来源工单号" prop="sourceWorkOrderNo" min-width="140" show-overflow-tooltip>
  <template #default="{ row }">{{ row.sourceWorkOrderNo || '—' }}</template>
</el-table-column>
<el-table-column label="来源任务号" prop="sourceTaskNo" min-width="140" show-overflow-tooltip>
  <template #default="{ row }">{{ row.sourceTaskNo || '—' }}</template>
</el-table-column>
  • Step 3: 验证列表页渲染

Run: cd /Users/wuzilong/work/00code/zkxw/web-admin && pnpm dev

浏览器访问 http://localhost:3006/#/production/cell-product,确认:

  1. 列表新增 4 列可见
  2. 搜索栏有「来源类型」下拉
  3. 选择「生产入库」筛选后列表正确过滤

Task 7: 前端 — 新增 SourceChainGraph 链路图组件

Files:

  • Create: web-admin/src/views/production/cell-product/SourceChainGraph.vue

  • Step 1: 创建链路图组件

新建 web-admin/src/views/production/cell-product/SourceChainGraph.vue,实现以下功能:

  • 接收 productId 和 visible props
  • visible 变为 true 时调用 fetchCellProductChain(productId) 获取链路数据
  • 渲染横向 DAG:祖先节点 → 当前节点 → 子代节点
  • 节点卡片样式区分:样本(浅蓝)、细胞产品(浅绿)、当前产品(深绿边框 + 角标)
  • 节点之间用 CSS border + 伪元素画水平连线和箭头
  • 连线上方显示工单号和任务号标签
  • 1对多分支使用竖线 + 横线分叉
  • 手动录入产品(起源节点)无祖先但可能有子代,只有既无祖先又无子代的孤立产品才显示空状态文字「该产品暂无来源及衍生链路信息」
  • 节点 hover 时用 el-popover 显示详情

组件核心模板结构:

<template>
  <div class="source-chain-graph" v-loading="loading">
    <div v-if="chainData" class="chain-container">
      <div class="chain-section-title">来源链路</div>

      <template v-if="chainData.ancestors.length || chainData.descendants.length">
        <div class="chain-row">
          <template v-for="(ancestor, idx) in flattenedAncestors" :key="'a-' + idx">
            <ChainNodeCard :node="ancestor.node" />
            <ChainConnector :edge="ancestor.edge" />
          </template>

          <ChainNodeCard :node="chainData.current" :is-current="true" />

          <template v-if="chainData.descendants.length">
            <ForkBranch :items="chainData.descendants" />
          </template>
        </div>
      </template>

      <div v-else class="empty-state">该产品暂无来源及衍生链路信息</div>
    </div>
  </div>
</template>

注意:实际实现中 ChainNodeCard、ChainConnector、ForkBranch 可以是同文件内的子组件或直接内联模板。核心样式参考 docs/demo-source-chain-graph.html。

  • Step 2: 验证组件无 TS 错误

Run: cd /Users/wuzilong/work/00code/zkxw/web-admin && npx vue-tsc --noEmit --pretty 2>&1 | head -20 Expected: 无新增错误


Task 8: 前端 — 查看弹窗集成链路图

Files:

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

  • Step 1: 在查看弹窗中集成 SourceChainGraph

在 index.vue 中:

  1. 导入组件:
import SourceChainGraph from './SourceChainGraph.vue'
  1. 在 SystemViewDialog 的 sections 配置中新增第三个 section(在产品图片 section 之后):
{
  label: '来源链路',
  slot: 'sourceChain',
}
  1. 在 SystemViewDialog 的模板中新增 slot:
<template #sourceChain>
  <SourceChainGraph :product-id="currentRow?.id" :visible="viewDialogVisible" />
</template>

注意:需要确认 SystemViewDialog 组件是否支持 slot 类型的 section。如果不支持,需要查看该组件源码并添加 slot 支持(通常是在 section 渲染时判断 slot 字段,用 <slot :name="section.slot" /> 渲染自定义内容)。

  • Step 2: 端到端验证
  1. 启动后端:cd backend && source venv/bin/activate && ./start-local.sh
  2. 启动前端:cd web-admin && pnpm dev
  3. 浏览器访问 http://localhost:3006/#/production/cell-product
  4. 点击任意产品的「查看」按钮
  5. 确认查看弹窗中有「来源链路」tab/section
  6. 确认链路图正确展示节点和连线
  7. 确认节点点击可显示详情 Popover
  8. 确认手动录入产品显示空状态

Task 9: 提交

  • Step 1: 检查变更范围

Run: cd /Users/wuzilong/work/00code/zkxw && git status Expected: 仅涉及以下文件:

  • backend/app/crud/cell_product.py

  • backend/app/schemas/cell_product.py

  • backend/app/api/production.py

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

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

  • web-admin/src/views/production/cell-product/SourceChainGraph.vue(新建)

  • docs/demo-source-chain-graph.html

  • docs/superpowers/specs/2026-05-24-cell-product-source-chain-design.md

  • docs/superpowers/plans/2026-05-24-cell-product-source-chain.md

  • Step 2: 提交

git add backend/app/crud/cell_product.py backend/app/schemas/cell_product.py backend/app/api/production.py web-admin/src/api/cell-product.ts web-admin/src/views/production/cell-product/ docs/
git commit -m "feat(cell-product): 添加细胞产品来源链路展示功能

列表新增来源类型、来源协议号、来源工单号、来源任务号四列,
支持来源类型筛选。查看弹窗新增来源链路图,展示祖先-当前-子代
双向追溯链路(横向 DAG,CSS 绘制连线),支持1对多分叉。"