docs: add pound QR public link implementation plan

Break the approved design into backend, shared QR view, public page,
copy-to-driver text, and driver footer tasks with TDD steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-13 09:22:05 +08:00
1 parent 157ad5e29a
commit 8994849292
1 file changed
+865
@@ -0,0 +1,865 @@
# 磅单二维码公开链接与司机页入口 Implementation Plan
> **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:** 复制给司机文案追加磅单二维码公开链接;独立公共页按出入库单 id 展示与管理端同款磅单二维码;司机填写页底栏增加「磅单二维码」按钮(多单先选再弹)。
**Architecture:** 抽出 `PoundSlipQrcodeView` 供管理端弹窗 / 公共页 / 司机弹层共用;新增免登 `GET /api/public/db/inventory/{id}/pound-qr`;司机页 payload 增加 `poundQrcodeItems`;`buildDriverSummaryFromPlan` 追加多行链接块。
**Tech Stack:** FastAPI · SQLAlchemy · Pydantic v2 · Vue 3 · Element Plus · Vant · pytest
**Spec:** `docs/superpowers/specs/2026-09-13-pound-qrcode-public-link-design.md`
**提交约定:** 默认每个 Task 末尾 commit;若用户要求暂不提交,可攒到全部完成后再提交。
---
## 文件地图
| 职责 | 路径 |
|------|------|
| 公开磅单 QR Schema | Modify `backend/app/schemas/db/public_inventory.py` |
| 公开磅单 QR API | Modify `backend/app/api/db/public_inventory.py` |
| 标签/装配 helper | Create `backend/app/services/db/pound_qr_public.py` |
| 公开 API 单测 | Create `backend/tests/test_public_db_pound_qr.py` |
| 库存关联读模型扩展 | Modify `backend/app/schemas/pl/transport.py`(`TransportInventoryLinkRead`、`PublicTransportPageRead`、新建 `PublicPoundQrcodeItemRead`) |
| 关联读构建 | Modify `backend/app/services/pl_inventory_link_read.py` |
| arrange-info 填字段 | Modify `backend/app/api/pl/plan.py`、`backend/app/api/pl/transport.py`(所有 `build_transport_inventory_link_read` 调用处补 business/partner) |
| 司机页填 items | Modify `backend/app/api/pl/public_transport.py` |
| 纯展示组件 | Create `web-admin/src/components/business/db/PoundSlipQrcodeView.vue` |
| 管理端弹窗改用 View | Modify `web-admin/src/components/business/db/DbPoundSlipQrcodeDialog.vue` |
| 公开 API 客户端 | Modify `web-admin/src/api/db/public-inventory.ts` |
| 公共页 | Create `web-admin/src/views/public/pl/pound-qrcode/index.vue` |
| 静态路由 | Modify `web-admin/src/router/routes/staticRoutes.ts` |
| 复制文案 | Modify `web-admin/src/utils/pl-driver-summary.ts` |
| 复制入口 | Modify `web-admin/src/composables/usePlPlanArrangeActions.ts`、`web-admin/src/components/business/pl-plan-view-tabs/index.vue` |
| FE 类型 | Modify `web-admin/src/api/pl/index.ts`、`web-admin/src/api/pl/public-transport.ts` |
| 司机页 UI | Modify `web-admin/src/views/public/pl/transport/index.vue` |
**不做:** token 化、限流、小程序、现场页底栏、改「复制磅单二维码」图片复制语义。
---
### Task 1: 后端公开 pound-qr Schema + helper + API + 单测
**Files:**
- Modify: `backend/app/schemas/db/public_inventory.py`
- Create: `backend/app/services/db/pound_qr_public.py`
- Modify: `backend/app/api/db/public_inventory.py`
- Create: `backend/tests/test_public_db_pound_qr.py`
- [ ] **Step 1: 写失败单测(helper + 404 路由)**
创建 `backend/tests/test_public_db_pound_qr.py`:
```python
"""公开磅单二维码只读接口。"""
from datetime import date
from types import SimpleNamespace
from fastapi.testclient import TestClient
from app.main import app
from app.services.db.pound_qr_public import (
build_pound_qr_label,
build_public_pound_qr_read,
)
client = TestClient(app)
def test_build_pound_qr_label():
assert build_pound_qr_label("出库", "合肥德泽") == "出库 · 合肥德泽"
assert build_pound_qr_label("入库", "") == "入库"
assert build_pound_qr_label("", "安庆山峡") == "安庆山峡"
def test_build_public_pound_qr_read_maps_fields():
row = SimpleNamespace(
id="inv-1",
date=date(2026, 9, 12),
business_type="出库",
vehicle_no="皖AM8516",
partner_company="合肥德泽化工科技有限公司",
product="30% 工业硫酸",
specification="30%",
quantity=10.0,
remark="注意安全",
)
read = build_public_pound_qr_read(row, quantity_unit="吨")
dump = read.model_dump(by_alias=True)
assert dump["id"] == "inv-1"
assert dump["date"] == "2026-09-12"
assert dump["businessType"] == "出库"
assert dump["vehicleNo"] == "皖AM8516"
assert dump["partnerCompany"] == "合肥德泽化工科技有限公司"
assert dump["product"] == "30% 工业硫酸"
assert dump["specification"] == "30%"
assert dump["quantity"] == 10.0
assert dump["quantityUnit"] == "吨"
assert dump["remarks"] == "注意安全"
def test_pound_qr_missing_returns_404():
resp = client.get("/api/public/db/inventory/__missing_pound_qr_id__/pound-qr")
assert resp.status_code in (404, 200)
if resp.status_code == 200:
body = resp.json()
# 项目 success_response 包装:业务码非 0 或 data 为空视为失败
assert body.get("code") not in (0, 200, "0", "200") or body.get("data") is None
```
说明:若项目 404 以 HTTP 404 抛出则断言 `status_code == 404`;若以 `success_response`/`error_response` 包一层,按仓库现有公开接口惯例调整断言(对照 `public_inventory.get` 与 `HTTPException`)。**实现时以 `HTTPException(404)` 为准,与 `_get_inventory_by_token` 一致。**
- [ ] **Step 2: 运行确认失败**
```bash
cd backend && source venv/bin/activate && pytest tests/test_public_db_pound_qr.py -v
```
Expected: FAIL(`pound_qr_public` 或 schema 未定义)
- [ ] **Step 3: 实现 Schema**
在 `backend/app/schemas/db/public_inventory.py` 追加:
```python
class PublicPoundQrRead(BaseModel):
id: str
date: Optional[str] = None
business_type: str = Field(alias="businessType")
vehicle_no: str = Field(alias="vehicleNo")
partner_company: str = Field(alias="partnerCompany")
product: str
specification: Optional[str] = None
quantity: float
quantity_unit: str = Field(default="吨", alias="quantityUnit")
remarks: Optional[str] = None
model_config = {"populate_by_name": True}
```
- [ ] **Step 4: 实现 helper**
创建 `backend/app/services/db/pound_qr_public.py`:
```python
from __future__ import annotations
from typing import Any, Optional
from app.schemas.db.public_inventory import PublicPoundQrRead
from app.services.db.mappers import inventory_date_to_api
def build_pound_qr_label(business_type: str, partner_company: str) -> str:
bt = (business_type or "").strip()
pc = (partner_company or "").strip()
if bt and pc:
return f"{bt} · {pc}"
return bt or pc or "磅单"
def build_public_pound_qr_read(row: Any, *, quantity_unit: Optional[str] = None) -> PublicPoundQrRead:
unit = (quantity_unit or "").strip() or "吨"
return PublicPoundQrRead(
id=row.id,
date=inventory_date_to_api(row.date) or None,
business_type=(row.business_type or "").strip() or "",
vehicle_no=(row.vehicle_no or "").strip(),
partner_company=(row.partner_company or "").strip(),
product=(row.product or "").strip(),
specification=(row.specification or None),
quantity=float(row.quantity or 0),
quantity_unit=unit,
remarks=(row.remark or None) if getattr(row, "remark", None) else None,
)
```
- [ ] **Step 5: 实现 API 路由**
在 `backend/app/api/db/public_inventory.py`:
1. import `PublicPoundQrRead`、`build_public_pound_qr_read`、`DbInboundOutbound`(已有)。
2. **在** `@router.get("/{token}")` **之前**注册:
```python
@router.get("/{inventory_id}/pound-qr")
def get_public_pound_qr(inventory_id: str, db: Session = Depends(get_db)):
row = (
db.query(DbInboundOutbound)
.filter(DbInboundOutbound.id == inventory_id, DbInboundOutbound.del_flag == 0)
.first()
)
if not row:
raise HTTPException(status_code=404, detail="链接无效或单据不存在")
read = build_public_pound_qr_read(row, quantity_unit="吨")
return success_response(read.model_dump(by_alias=True))
```
- [ ] **Step 6: 跑通单测**
```bash
cd backend && source venv/bin/activate && pytest tests/test_public_db_pound_qr.py -v
```
Expected: PASS(404 用例按 Step 1 最终断言)
- [ ] **Step 7: Commit**
```bash
git add backend/app/schemas/db/public_inventory.py \
backend/app/services/db/pound_qr_public.py \
backend/app/api/db/public_inventory.py \
backend/tests/test_public_db_pound_qr.py
git commit -m "$(cat <<'EOF'
feat(backend): add public pound-qr endpoint by inventory id
Expose a read-only payload for the shared weighbridge QR card without auth.
EOF
)"
```
---
### Task 2: 扩展 inventory link 与司机页 poundQrcodeItems
**Files:**
- Modify: `backend/app/schemas/pl/transport.py`
- Modify: `backend/app/services/pl_inventory_link_read.py`
- Modify: `backend/app/api/pl/plan.py`(arrange-info 组装循环)
- Modify: `backend/app/api/pl/transport.py`(arrange 结果组装,凡调用 `build_transport_inventory_link_read` 处)
- Modify: `backend/app/api/pl/public_transport.py`
- Create: `backend/tests/test_pound_qr_label_items.py`(纯函数测 label;items 装配可测 helper)
- [ ] **Step 1: 扩展 Schema**
`TransportInventoryLinkRead` 增加可选字段:
```python
business_type: Optional[str] = Field(default=None, alias="businessType")
partner_company: Optional[str] = Field(default=None, alias="partnerCompany")
```
新增:
```python
class PublicPoundQrcodeItemRead(BaseModel):
inventory_id: str = Field(alias="inventoryId")
business_type: str = Field(alias="businessType")
partner_company: str = Field(alias="partnerCompany")
label: str
model_config = {"populate_by_name": True}
```
`PublicTransportPageRead` 增加:
```python
pound_qrcode_items: list[PublicPoundQrcodeItemRead] = Field(
default_factory=list, alias="poundQrcodeItems"
)
```
- [ ] **Step 2: 更新 `build_transport_inventory_link_read`**
```python
def build_transport_inventory_link_read(
db,
*,
plan_product_id: str,
product_name: str,
db_purchase_plan_id: str,
db_inventory_id: str,
batch_no: str,
plan_no: str,
stop_type: str | None = None,
stop_id: str | None = None,
business_type: str | None = None,
partner_company: str | None = None,
) -> TransportInventoryLinkRead:
del db
return TransportInventoryLinkRead(
plan_product_id=plan_product_id,
product_name=product_name,
db_purchase_plan_id=db_purchase_plan_id,
db_inventory_id=db_inventory_id,
batch_no=batch_no,
plan_no=plan_no,
stop_type=stop_type,
stop_id=stop_id,
business_type=business_type,
partner_company=partner_company,
)
```
所有调用处在已有 `inventory` 对象时传入:
```python
business_type=(inventory.business_type if inventory else None),
partner_company=(inventory.partner_company if inventory else None),
```
(`plan.py` arrange-info 循环已查 `inventory`;`transport.py` 同样在有 inventory 时补齐。用 `rg "build_transport_inventory_link_read"` 扫全仓改全。)
- [ ] **Step 3: 在 `pound_qr_public.py` 增加司机 items 装配**
```python
def build_pound_qrcode_items_for_plan(
db,
*,
tenant_no: str,
plan_id: str,
transport_id: str,
) -> list:
from app.models.db_models import DbInboundOutbound
from app.models.pl_models import PlTransportDbLink
from app.schemas.pl.transport import PublicPoundQrcodeItemRead
links = (
db.query(PlTransportDbLink)
.filter(
PlTransportDbLink.tenant_no == tenant_no,
PlTransportDbLink.plan_id == plan_id,
PlTransportDbLink.transport_id == transport_id,
PlTransportDbLink.del_flag == 0,
)
.all()
)
items = []
seen = set()
for link in links:
inv_id = (link.db_inventory_id or "").strip()
if not inv_id or inv_id in seen:
continue
if not (link.db_purchase_plan_id or "").strip():
continue
inv = (
db.query(DbInboundOutbound)
.filter(
DbInboundOutbound.id == inv_id,
DbInboundOutbound.tenant_no == tenant_no,
DbInboundOutbound.del_flag == 0,
)
.first()
)
if not inv:
continue
seen.add(inv_id)
bt = (inv.business_type or "").strip()
pc = (inv.partner_company or "").strip()
items.append(
PublicPoundQrcodeItemRead(
inventory_id=inv.id,
business_type=bt,
partner_company=pc,
label=build_pound_qr_label(bt, pc),
)
)
return items
```
说明:`db_purchase_plan_id` 非空与前端 `dibangInventoryRecords` 过滤一致(勾选生成地磅单)。
- [ ] **Step 4: `public_transport._build_public_page` 填入 items**
在构造 `PublicTransportPageRead(...)` 前:
```python
pound_items = []
if plan:
from app.services.db.pound_qr_public import build_pound_qrcode_items_for_plan
pound_items = build_pound_qrcode_items_for_plan(
db,
tenant_no=transport.tenant_no,
plan_id=plan.id,
transport_id=transport.id,
)
```
并传入 `pound_qrcode_items=pound_items`。
- [ ] **Step 5: 单测 label + purchase_plan 过滤逻辑(可抽纯函数测 seen/filter)**
在 `backend/tests/test_pound_qr_label_items.py`:
```python
from app.services.db.pound_qr_public import build_pound_qr_label
def test_label_join():
assert " · " in build_pound_qr_label("出库", "A")
```
(items 装配依赖 DB,本 Task 以手工联调 + Task 1 风格为主;若有现成 DB fixture 可补集成测,无则跳过。)
- [ ] **Step 6: Commit**
```bash
git add backend/app/schemas/pl/transport.py \
backend/app/services/pl_inventory_link_read.py \
backend/app/services/db/pound_qr_public.py \
backend/app/api/pl/plan.py \
backend/app/api/pl/transport.py \
backend/app/api/pl/public_transport.py \
backend/tests/test_pound_qr_label_items.py
git commit -m "$(cat <<'EOF'
feat(backend): expose pound QR items on driver transport page
Enrich inventory links with business type/partner and attach
poundQrcodeItems for the public driver footer button.
EOF
)"
```
---
### Task 3: 抽出 `PoundSlipQrcodeView` 并改管理端弹窗
**Files:**
- Create: `web-admin/src/components/business/db/PoundSlipQrcodeView.vue`
- Modify: `web-admin/src/components/business/db/DbPoundSlipQrcodeDialog.vue`
- [ ] **Step 1: 新建 View 组件**
`PoundSlipQrcodeView.vue` props:
```ts
defineProps<{
vehicleNo: string
partnerCompany: string
productLabel: string // 已拼好「名称(规格)(数量 单位)」或按行拆分
quantityText: string // 若拆分:数量展示
remarks?: string
dateText: string
idLabelPrefix: string // 「出库单ID:」或「入库单ID:」
inventoryId: string
qrObjectUrl: string
}>()
```
模板结构从现有 Dialog 的 `.pound-slip-capture-inline` 内两张 `capture-card` 原样搬出(车牌 / 往来单位 / 物料 / 备注 / id+二维码+说明)。**不**包含 `el-dialog` / loading。
更稳妥的 props 对齐现弹窗字段拆分:
```ts
{
vehicleNo: string
partnerCompany: string
productLine: string // formatQrcodeProductLabel + (qty unit)
remarks?: string
dateText: string
businessType: string // 入库|出库
inventoryId: string
qrObjectUrl: string
}
```
物料行内部:`{{ productLine }}`;id 行:`{{ dateText }} {{ businessType === '出库' ? '出库单ID:' : '入库单ID:' }}{{ inventoryId }}`。
- [ ] **Step 2: Dialog 改为加载后渲染 View**
`DbPoundSlipQrcodeDialog.vue`:
- 保留 `loadQrcode` / `copyQrcodeImage` / `inventoryApi.get`
- template body 改为:
```vue
<div v-loading="loading" class="db-qrcode-body">
<PoundSlipQrcodeView
v-if="captureRow && captureQrObjectUrl"
:vehicle-no="captureRow.vehicleNo"
:partner-company="captureRow.partnerCompany"
:product-line="formatProductLine(captureRow)"
:remarks="captureRow.remarks || ''"
:date-text="captureRow.date"
:business-type="captureRow.businessType"
:inventory-id="captureRow.id"
:qr-object-url="captureQrObjectUrl"
/>
</div>
```
`formatProductLine` = 现 `formatQrcodeProductLabel` + `(${quantity} ${quantityUnitLabel})`。
- [ ] **Step 3: 本地冒烟**
管理端打开任一带地磅单的计划 →「复制磅单二维码」→ 弹窗样式与改前一致,复制图片仍成功。
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/components/business/db/PoundSlipQrcodeView.vue \
web-admin/src/components/business/db/DbPoundSlipQrcodeDialog.vue
git commit -m "$(cat <<'EOF'
refactor(web-admin): extract PoundSlipQrcodeView for shared QR card
Keep admin dialog behavior while enabling public page and driver popup reuse.
EOF
)"
```
---
### Task 4: 公共页路由 + API 客户端 + 页面
**Files:**
- Modify: `web-admin/src/api/db/public-inventory.ts`
- Create: `web-admin/src/views/public/pl/pound-qrcode/index.vue`
- Modify: `web-admin/src/router/routes/staticRoutes.ts`
- [ ] **Step 1: API 客户端**
在 `public-inventory.ts` 增加类型与方法:
```ts
export interface PublicPoundQr {
id: string
date?: string
businessType: string
vehicleNo: string
partnerCompany: string
product: string
specification?: string
quantity: number
quantityUnit?: string
remarks?: string
}
// publicInventoryApi 或独立 export:
getPoundQr: (inventoryId: string) =>
publicPlGet<PublicPoundQr>(`/public/db/inventory/${inventoryId}/pound-qr`),
```
二维码图 URL 生成与 Dialog 相同:
```ts
`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(id)}`
```
- [ ] **Step 2: 静态路由**
在 `/public/db/inventory/:token` **之前**插入(避免被其它规则影响;与 catch-all 无关):
```ts
{
path: '/public/pl/pound-qrcode/:inventoryId',
name: 'PublicPlPoundQrcode',
component: () => import('@views/public/pl/pound-qrcode/index.vue'),
meta: { title: '磅单二维码', isHideTab: true, isPublicMobile: true }
},
```
- [ ] **Step 3: 公共页**
`views/public/pl/pound-qrcode/index.vue`:
- `PublicMobileShell` + 深色背景容器(对齐 `.db-qrcode-overlay`:`background: rgba(0,0,0,0.92)`)
- 标题:加载后按 `businessType` 显示「出库磅单二维码」/「入库磅单二维码」
- `onMounted`:读 `route.params.inventoryId` → `getPoundQr` → fetch 二维码 blob → `PoundSlipQrcodeView`
- 失败:`van-empty` description=`链接无效或单据不存在` 或接口 message
- 引入 `@/styles/db-module.scss` 中磅单相关类(若公共页未全局引入,在本页 `import '@/styles/db-module.scss'` 或仅依赖已有全局;检查 `main.ts` / 布局是否已引入 `db-module.scss`,没有则本页 import)
产品行拼接逻辑复制 Dialog 的 `formatQrcodeProductLabel`。
- [ ] **Step 4: 手工打开**
`/#/public/pl/pound-qrcode/{真实出入库单id}` 应显示黄牌卡。
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/api/db/public-inventory.ts \
web-admin/src/views/public/pl/pound-qrcode/index.vue \
web-admin/src/router/routes/staticRoutes.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add public pound QR page by inventory id
Drivers can open a standalone link that renders the shared weighbridge QR card.
EOF
)"
```
---
### Task 5: 「复制给司机」文案追加磅单链接
**Files:**
- Modify: `web-admin/src/utils/pl-driver-summary.ts`
- Modify: `web-admin/src/api/pl/index.ts`(`TransportInventoryLink` 类型加 `businessType?` / `partnerCompany?`)
- Modify: `web-admin/src/composables/usePlPlanArrangeActions.ts`
- Modify: `web-admin/src/components/business/pl-plan-view-tabs/index.vue`
- [ ] **Step 1: 扩展 `buildDriverSummaryFromPlan`**
在 options 增加:
```ts
poundQrcodeLinks?: { label: string; url: string }[]
```
在 `lines.push(\`司机链接:...\`)` **之后**:
```ts
const links = (options.poundQrcodeLinks || []).filter((x) => x?.url)
if (links.length) {
lines.push('')
lines.push('磅单二维码链接:')
for (const item of links) {
lines.push(`${item.label}:${item.url}`)
}
}
```
- [ ] **Step 2: 小工具函数(可放同文件或 composable)**
```ts
export function buildPoundQrcodePublicUrl(inventoryId: string, origin = window.location.origin) {
return `${origin}/#/public/pl/pound-qrcode/${encodeURIComponent(inventoryId)}`
}
export function buildPoundQrcodeLinksFromInventoryRecords(
records: { dbInventoryId: string; dbPurchasePlanId?: string; businessType?: string; partnerCompany?: string }[],
origin = window.location.origin
) {
return records
.filter((r) => r.dbPurchasePlanId?.trim() && r.dbInventoryId?.trim())
.map((r) => {
const bt = (r.businessType || '').trim()
const pc = (r.partnerCompany || '').trim()
const label = bt && pc ? `${bt} · ${pc}` : bt || pc || '磅单'
return { label, url: buildPoundQrcodePublicUrl(r.dbInventoryId, origin) }
})
}
```
- [ ] **Step 3: `usePlPlanArrangeActions.buildPlanSummaryText`**
合并 options 时自动附带:
```ts
poundQrcodeLinks:
options?.poundQrcodeLinks ??
buildPoundQrcodeLinksFromInventoryRecords(dibangInventoryRecords.value),
```
(若 options 显式传空数组则尊重。)
- [ ] **Step 4: `pl-plan-view-tabs` 的 `copyDriverSummaryForArrange`**
同样传入 `poundQrcodeLinks`:从 `props.arrangeInventoryRecords`(若无此 prop,用已有 arrange inventory 来源;**查该组件 props**——若只有 site/driver,则改为调用 `usePlPlanArrangeActions` 的 `copyPlanSummary`,或增加 `arrangeInventoryRecords` prop)。
实现时:`rg "copyDriverSummaryForArrange|arrangeInventory|inventoryRecords" web-admin/src/components/business/pl-plan-view*`,保证两条复制入口都带链接。列表页「复制给司机」走 `usePlPlanArrangeActions.copyPlanSummary` 即可。
- [ ] **Step 5: 手工验证**
有地磅单计划:复制文本末尾出现标题 + 多行。无地磅单:无该段。
- [ ] **Step 6: Commit**
```bash
git add web-admin/src/utils/pl-driver-summary.ts \
web-admin/src/api/pl/index.ts \
web-admin/src/composables/usePlPlanArrangeActions.ts \
web-admin/src/components/business/pl-plan-view-tabs/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): append pound QR links in copy-to-driver text
Include multi-line public pound QR URLs after the driver link when dibang slips exist.
EOF
)"
```
---
### Task 6: 司机页底栏第三钮 + 弹层
**Files:**
- Modify: `web-admin/src/api/pl/public-transport.ts`
- Modify: `web-admin/src/views/public/pl/transport/index.vue`
- [ ] **Step 1: 类型**
`PublicTransportPage` 增加:
```ts
poundQrcodeItems?: {
inventoryId: string
businessType: string
partnerCompany: string
label: string
}[]
```
- [ ] **Step 2: 底栏 UI**
在 `footer` 的可编辑分支(`!planVoided && !planArchived`):
```vue
<div class="btn-row" :class="{ 'btn-row--three': hasPoundQr }">
<van-button ... @click="goHistory">运输记录</van-button>
<van-button
v-if="hasPoundQr"
block
round
plain
type="primary"
@click="handleOpenPoundQr"
>
磅单二维码
</van-button>
<van-button type="primary" block round :loading="saving" @click="handleSave">
保存回执
</van-button>
</div>
```
作废/归档分支:若 `hasPoundQr`,在「运输记录」旁或下方仍提供「磅单二维码」按钮(spec D:只读核验)。建议:
```vue
<div v-else class="btn-row" :class="{ 'btn-row--three': hasPoundQr }">
<van-button ... @click="goHistory">运输记录</van-button>
<van-button v-if="hasPoundQr" ... @click="handleOpenPoundQr">磅单二维码</van-button>
</div>
```
`hasPoundQr = computed(() => (page.value?.poundQrcodeItems?.length || 0) > 0)`
样式:三钮时缩小字号/gap(可参考 Demo;`.btn-row { display:flex; gap:8px }` 已有则加 `--three` 子按钮 `flex:1; font-size:13px`)。
- [ ] **Step 3: 交互逻辑**
```ts
import { showActionSheet, showFailToast } from 'vant'
import { publicInventoryApi } from '@/api/db/public-inventory'
import PoundSlipQrcodeView from '@/components/business/db/PoundSlipQrcodeView.vue'
const poundPopupVisible = ref(false)
const poundLoading = ref(false)
const poundView = ref<null | { /* View props */ }>(null)
async function openPoundForId(inventoryId: string) {
poundLoading.value = true
poundPopupVisible.value = true
poundView.value = null
try {
const res = await publicInventoryApi.getPoundQr(inventoryId)
const row = (res as any).data ?? res
// fetch qr blob → objectUrl
// set poundView props + format product line
} catch (e: any) {
showFailToast(e?.message || '加载二维码失败')
poundPopupVisible.value = false
} finally {
poundLoading.value = false
}
}
async function handleOpenPoundQr() {
const items = page.value?.poundQrcodeItems || []
if (!items.length) return
if (items.length === 1) {
await openPoundForId(items[0].inventoryId)
return
}
const action = await showActionSheet({
actions: items.map((i) => ({ name: i.label })),
cancelText: '取消'
}).catch(() => null)
if (!action) return
const idx = items.findIndex((i) => i.label === action.name) // 或用 actions 带 value
// 更好:actions: items.map(i => ({ name: i.label, inventoryId: i.inventoryId }))
// Vant ActionSheet onSelect 返回 action 对象
await openPoundForId(selected.inventoryId)
}
```
弹层:
```vue
<van-popup
v-model:show="poundPopupVisible"
position="center"
round
:style="{ width: '92%', background: 'transparent' }"
class="pound-qr-popup"
>
<div class="pound-qr-popup__mask">
<div class="pound-qr-popup__title">{{ poundTitle }}</div>
<PoundSlipQrcodeView v-if="poundView" v-bind="poundView" />
</div>
</van-popup>
```
遮罩深色:`background: rgba(0,0,0,0.92)`;关闭用 popup 自带点击遮罩。
- [ ] **Step 4: 手工验证**
- 有 1 条:点按钮直接弹
- 有多条:ActionSheet → 弹层
- 无条:无按钮
- 与独立公共页样式一致
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/api/pl/public-transport.ts \
web-admin/src/views/public/pl/transport/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): add pound QR button on public driver transport page
Show a third footer action that opens the shared weighbridge QR card popup.
EOF
)"
```
---
### Task 7: 收尾校验 + 图谱
**Files:** 无新功能文件;跑测与图谱
- [ ] **Step 1: 后端单测**
```bash
cd backend && source venv/bin/activate && pytest tests/test_public_db_pound_qr.py tests/test_pound_qr_label_items.py -v
```
Expected: PASS
- [ ] **Step 2: 对照 spec 验收清单**
逐条勾选 spec §8:单张文案、多张选择、无单隐藏、管理端复制图片、作废只读、删单 404。
- [ ] **Step 3: 更新 graphify**
```bash
./scripts/update-graph.sh all
```
或分别 `backend` / `frontend`。
- [ ] **Step 4: Commit 图谱(若有 diff)**
```bash
git add backend/graphify-out web-admin/graphify-out graphify-out
git commit -m "chore: update graphify after pound QR public link"
```
---
## Spec 覆盖自检
| Spec 项 | Task |
|---------|------|
| D1/D4 多行文案格式 | Task 5 |
| D2 司机多选再弹 | Task 6 |
| D3 公开 id 路由 | Task 4 |
| D5 无单隐藏 | Task 5 + 6 |
| D6 三钮同行 | Task 6 |
| D7 方案 1 组件+API | Task 1–4 |
| D8 作废仍可看 | Task 6 作废分支仍显示按钮 |
| 公开字段白名单 | Task 1 Schema |
| 管理端弹窗不回退 | Task 3 |
| Demo 仅参考 | 不改代码路径 |
**Placeholder 扫描:** 无 TBD;Task 5 中 view-tabs prop 名称要求实现时用 `rg` 确认,已写明查找命令。
**类型一致性:** `poundQrcodeItems` / `PublicPoundQr` / `poundQrcodeLinks` 命名与 spec 一致;API 路径 `/public/db/inventory/{id}/pound-qr`。