26 KiB
细胞产品库 — 来源链路展示 实施计划
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 函数中:
- 在参数列表
passage之后新增:sourceType: Optional[str] = None, - 在
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 函数中:
- 在参数列表
passage之后新增:source_type: Optional[str] = None, - 在
_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,确认:
- 列表新增 4 列可见
- 搜索栏有「来源类型」下拉
- 选择「生产入库」筛选后列表正确过滤
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和visibleprops 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 中:
- 导入组件:
import SourceChainGraph from './SourceChainGraph.vue'
- 在
SystemViewDialog的 sections 配置中新增第三个 section(在产品图片 section 之后):
{
label: '来源链路',
slot: 'sourceChain',
}
- 在
SystemViewDialog的模板中新增 slot:
<template #sourceChain>
<SourceChainGraph :product-id="currentRow?.id" :visible="viewDialogVisible" />
</template>
注意:需要确认 SystemViewDialog 组件是否支持 slot 类型的 section。如果不支持,需要查看该组件源码并添加 slot 支持(通常是在 section 渲染时判断 slot 字段,用 <slot :name="section.slot" /> 渲染自定义内容)。
- Step 2: 端到端验证
- 启动后端:
cd backend && source venv/bin/activate && ./start-local.sh - 启动前端:
cd web-admin && pnpm dev - 浏览器访问
http://localhost:3006/#/production/cell-product - 点击任意产品的「查看」按钮
- 确认查看弹窗中有「来源链路」tab/section
- 确认链路图正确展示节点和连线
- 确认节点点击可显示详情 Popover
- 确认手动录入产品显示空状态
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对多分叉。"