docs: add implementation plan for inventory pound QR popup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-24 21:09:55 +08:00
1 parent 71f3fde31c
commit a6e0c4481f
1 file changed
+677
@@ -0,0 +1,677 @@
# 小程序出入库列表 · 磅单二维码弹窗 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:** 出入库管理列表去掉「生成地磅单」,改为主色「磅单二维码」按钮,弹出与运输计划一致的居中磅单二维码卡片(本地生成 QR,内容为出入库单 `id`)。
**Architecture:** 抽取纯函数工具(标题/物料行/备注归一)便于单测;新建居中弹窗组件 `db-pound-qrcode-popup`(样式对齐 `PoundSlipQrcodeView`);列表页只负责开/关与传 `inventoryId`。二维码用 `uqrcodejs` 画到隐藏 canvas 再导出临时路径展示,不依赖外网 QR 图床。
**Tech Stack:** uni-app Vue3 小程序、`uqrcodejs`、现有 `inventoryApi` / `db-btn`
**Spec:** `docs/superpowers/specs/2026-09-24-mp-inventory-pound-qrcode-popup-design.md`
**视觉参考:** `web-admin/src/components/business/db/PoundSlipQrcodeView.vue`、`web-admin/src/styles/db-module.scss`(`.pound-slip-capture-inline`)、运输计划 webview `DbPoundSlipQrcodeDialog`
---
## 文件结构
| 路径 | 职责 |
|------|------|
| `miniprogram/utils/dbPoundQr.js` | 纯函数:弹窗标题、物料行文案、备注字段归一 |
| `miniprogram/scripts/test_db_pound_qr.mjs` | Node 断言跑上述纯函数(小程序无 jest) |
| `miniprogram/utils/makeQrTempFile.js` | 用 `uqrcodejs` + canvas 生成临时图片路径 |
| `miniprogram/components/db-ui/db-pound-qrcode-popup.vue` | 居中弹窗 UI + 拉详情 + 展示 QR |
| `miniprogram/pages/db/inventory/list.vue` | 按钮替换、挂载弹窗、删除 `genWeighing` |
| `miniprogram/package.json` | 增加依赖 `uqrcodejs` |
---
### Task 1: 纯函数工具 + 单测
**Files:**
- Create: `miniprogram/utils/dbPoundQr.js`
- Create: `miniprogram/scripts/test_db_pound_qr.mjs`
- [ ] **Step 1: 写失败用例脚本(先测再实现)**
`miniprogram/scripts/test_db_pound_qr.mjs`:
```js
import assert from 'node:assert/strict'
import {
poundQrDialogTitle,
formatPoundQrProductLine,
normalizePoundQrRemarks
} from '../utils/dbPoundQr.js'
assert.equal(poundQrDialogTitle('出库'), '出库磅单二维码')
assert.equal(poundQrDialogTitle('入库'), '入库磅单二维码')
assert.equal(poundQrDialogTitle(''), '入库磅单二维码')
assert.equal(formatPoundQrProductLine({ product: '尿素', specification: '', quantity: 32 }, '吨'), '尿素(32 吨)')
assert.equal(
formatPoundQrProductLine({ product: '尿素颗粒', specification: '颗粒', quantity: 10 }, '吨'),
'尿素 颗粒(10 吨)'
)
assert.equal(
formatPoundQrProductLine({ product: '尿素', specification: '46%', quantity: 1 }, '吨'),
'尿素 46%(1 吨)'
)
assert.equal(normalizePoundQrRemarks({ remarks: '急单' }), '急单')
assert.equal(normalizePoundQrRemarks({ remark: '备货' }), '备货')
assert.equal(normalizePoundQrRemarks({ remarks: ' ', remark: '' }), '')
console.log('test_db_pound_qr: ok')
```
- [ ] **Step 2: 跑测试确认失败**
Run:
```bash
cd miniprogram && node scripts/test_db_pound_qr.mjs
```
Expected: `ERR_MODULE_NOT_FOUND` 或类似(`dbPoundQr.js` 尚不存在)。
- [ ] **Step 3: 实现纯函数**
`miniprogram/utils/dbPoundQr.js`:
```js
/**
* 磅单二维码弹窗文案工具(与 web-admin PoundSlipQrcodeView / DbPoundSlipQrcodeDialog 对齐)
*/
export function poundQrDialogTitle(businessType) {
return String(businessType || '').trim() === '出库' ? '出库磅单二维码' : '入库磅单二维码'
}
/** 与 DbPoundSlipQrcodeDialog.formatQrcodeProductLabel 一致 */
export function formatQrcodeProductLabel(row) {
const product = String(row?.product || '').trim()
const specification = String(row?.specification || '').trim()
if (!specification) return product
if (product.endsWith(specification)) {
const base = product.slice(0, -specification.length).trimEnd()
return base ? `${base} ${specification}` : specification
}
return `${product} ${specification}`.trim()
}
export function formatPoundQrProductLine(row, quantityUnit = '吨') {
const unit = String(quantityUnit || '').trim() || '吨'
const label = formatQrcodeProductLabel(row)
const qty = row?.quantity
const qtyText = qty === 0 || qty ? String(qty) : '--'
return `${label}(${qtyText} ${unit})`
}
export function normalizePoundQrRemarks(row) {
const a = String(row?.remarks ?? '').trim()
if (a) return a
return String(row?.remark ?? '').trim()
}
```
- [ ] **Step 4: 再跑测试确认通过**
```bash
cd miniprogram && node scripts/test_db_pound_qr.mjs
```
Expected: `test_db_pound_qr: ok`
- [ ] **Step 5: Commit**
```bash
git add miniprogram/utils/dbPoundQr.js miniprogram/scripts/test_db_pound_qr.mjs
git commit -m "$(cat <<'EOF'
feat(miniprogram): add pound QR label helpers and node checks.
EOF
)"
```
---
### Task 2: 安装 uqrcodejs + 生成临时二维码图
**Files:**
- Modify: `miniprogram/package.json`
- Create: `miniprogram/utils/makeQrTempFile.js`
- [ ] **Step 1: 安装依赖**
在 `miniprogram` 目录:
```bash
cd miniprogram && npm install uqrcodejs --save
```
确认 `package.json` 的 `dependencies` 含 `"uqrcodejs"`。
- [ ] **Step 2: 实现生成工具**
`miniprogram/utils/makeQrTempFile.js`:
```js
import UQRCode from 'uqrcodejs'
/**
* 在组件内隐藏 canvas 上绘制二维码,导出临时文件路径供 <image> 展示。
* @param {object} opts
* @param {string} opts.text 二维码内容(出入库单 id)
* @param {string} opts.canvasId canvas-id
* @param {object} opts.componentInstance 组件实例(getCurrentInstance().proxy)
* @param {number} [opts.size=200]
* @returns {Promise<string>} tempFilePath
*/
export function makeQrTempFile({ text, canvasId, componentInstance, size = 200 }) {
const content = String(text || '').trim()
if (!content) {
return Promise.reject(new Error('二维码内容为空'))
}
if (!componentInstance) {
return Promise.reject(new Error('缺少组件实例'))
}
return new Promise((resolve, reject) => {
try {
const qr = new UQRCode()
qr.data = content
qr.size = size
qr.margin = 10
qr.make()
const ctx = uni.createCanvasContext(canvasId, componentInstance)
qr.canvasContext = ctx
qr.drawCanvas()
// drawCanvas 内部会 ctx.draw;稍延迟再导出
setTimeout(() => {
uni.canvasToTempFilePath(
{
canvasId,
width: size,
height: size,
destWidth: size,
destHeight: size,
success: (res) => {
if (res.tempFilePath) resolve(res.tempFilePath)
else reject(new Error('二维码导出失败'))
},
fail: (err) => reject(err || new Error('二维码导出失败'))
},
componentInstance
)
}, 120)
} catch (e) {
reject(e)
}
})
}
```
说明:若真机 `drawCanvas` 与导出时序不稳,实现时可改为 `uQRCode` 文档中的 `make({ canvasId, componentInstance, text, size, success })` 成功回调拿 path/base64,但**禁止**改回外网 `api.qrserver.com`。
- [ ] **Step 3: Commit**
```bash
git add miniprogram/package.json miniprogram/package-lock.json miniprogram/utils/makeQrTempFile.js
git commit -m "$(cat <<'EOF'
feat(miniprogram): add local QR temp-file helper via uqrcodejs.
EOF
)"
```
(若无 lock 文件则只 add 实际存在的依赖清单文件。)
---
### Task 3: 新建 `db-pound-qrcode-popup` 组件
**Files:**
- Create: `miniprogram/components/db-ui/db-pound-qrcode-popup.vue`
- Test: 手工打开弹窗(Task 5);纯函数已在 Task 1 覆盖
- [ ] **Step 1: 创建组件**
`miniprogram/components/db-ui/db-pound-qrcode-popup.vue`(完整实现):
```vue
<template>
<view v-if="show" class="pound-qr-mask" @click="close">
<view class="pound-qr-panel" @click.stop>
<view class="pound-qr-panel__title">{{ title }}</view>
<view v-if="loading" class="pound-qr-panel__loading">加载中...</view>
<view v-else-if="row" class="pound-slip-capture-inline">
<view class="capture-card">
<view class="capture-row capture-row-plate">
<view class="vehicle-plate">
<view class="vehicle-plate-dot vehicle-plate-dot-lt" />
<view class="vehicle-plate-dot vehicle-plate-dot-lb" />
<text class="vehicle-plate-text">{{ row.vehicleNo || '未填车牌' }}</text>
<view class="vehicle-plate-dot vehicle-plate-dot-rt" />
<view class="vehicle-plate-dot vehicle-plate-dot-rb" />
</view>
</view>
<view class="capture-row capture-row-large">
<text class="label">往来单位:</text>
<text class="value">{{ row.partnerCompany || '--' }}</text>
</view>
<view class="capture-row capture-row-large">
<text class="label">物料名称:</text>
<text class="value">{{ productLine }}</text>
</view>
<view v-if="remarksText" class="capture-row">
<text class="label">备注:</text>
<text class="value">{{ remarksText }}</text>
</view>
</view>
<view class="capture-card capture-qrcode">
<view class="capture-id">
{{ row.date || '' }}
{{ row.businessType === '出库' ? '出库单ID:' : '入库单ID:' }}{{ row.id }}
</view>
<view class="capture-qr-wrap">
<image v-if="qrPath" class="capture-qr-img" :src="qrPath" mode="aspectFit" />
</view>
<view class="capture-desc">出入库单标识(扫码核验)</view>
</view>
</view>
<!-- 隐藏 canvas,供 uqrcodejs 绘制 -->
<canvas
canvas-id="poundQrCanvas"
id="poundQrCanvas"
class="pound-qr-canvas"
:style="{ width: canvasSize + 'px', height: canvasSize + 'px' }"
/>
</view>
</view>
</template>
<script setup>
import { computed, getCurrentInstance, ref, watch } from 'vue'
import { inventoryApi } from '@/pages/db/api/db'
import {
formatPoundQrProductLine,
normalizePoundQrRemarks,
poundQrDialogTitle
} from '@/utils/dbPoundQr'
import { makeQrTempFile } from '@/utils/makeQrTempFile'
const props = defineProps({
show: Boolean,
inventoryId: { type: String, default: '' },
/** 列表行快照(可选);仍会按需 get 补全 */
snapshot: { type: Object, default: null }
})
const emit = defineEmits(['close', 'update:show'])
const loading = ref(false)
const row = ref(null)
const qrPath = ref('')
const canvasSize = 200
const instance = getCurrentInstance()
const title = computed(() => poundQrDialogTitle(row.value?.businessType))
const productLine = computed(() =>
row.value ? formatPoundQrProductLine(row.value, '吨') : ''
)
const remarksText = computed(() => (row.value ? normalizePoundQrRemarks(row.value) : ''))
function close() {
emit('update:show', false)
emit('close')
}
function reset() {
row.value = null
qrPath.value = ''
loading.value = false
}
async function load() {
const id = String(props.inventoryId || '').trim()
if (!id) {
uni.showToast({ title: '缺少单据ID', icon: 'none' })
close()
return
}
loading.value = true
row.value = null
qrPath.value = ''
try {
let data = props.snapshot && props.snapshot.id === id ? { ...props.snapshot } : null
if (!data || data.quantity == null || !data.partnerCompany) {
const res = await inventoryApi.get(id)
data = res?.data ?? res
}
if (!data || !data.id) throw new Error('单据不存在')
row.value = data
await new Promise((r) => setTimeout(r, 50))
const proxy = instance?.proxy
qrPath.value = await makeQrTempFile({
text: data.id,
canvasId: 'poundQrCanvas',
componentInstance: proxy,
size: canvasSize
})
} catch (e) {
uni.showToast({ title: e?.message || '加载二维码失败', icon: 'none' })
close()
} finally {
loading.value = false
}
}
watch(
() => [props.show, props.inventoryId],
([show]) => {
if (show) load()
else reset()
}
)
</script>
<style scoped lang="scss">
.pound-qr-mask {
position: fixed;
inset: 0;
z-index: 1100;
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
box-sizing: border-box;
}
.pound-qr-panel {
width: 100%;
max-width: 360px;
max-height: 90vh;
overflow: auto;
background: #f5f7fa;
border-radius: 12px;
position: relative;
}
.pound-qr-panel__title {
text-align: center;
font-size: 17px;
font-weight: 700;
color: #1e293b;
padding: 14px 12px 6px;
}
.pound-qr-panel__loading {
text-align: center;
color: #9ca3af;
padding: 40px 0;
font-size: 14px;
}
.pound-qr-canvas {
position: fixed;
left: -9999px;
top: 0;
width: 200px;
height: 200px;
}
.pound-slip-capture-inline {
background: #f5f7fa;
padding: 4px 8px 12px;
margin: 0 6px 10px;
}
.capture-card {
background: #fff;
border-radius: 8px;
margin: 10px;
padding: 6px 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.capture-row {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 4px;
font-size: 13px;
}
.capture-row-large {
font-size: 15px;
font-weight: 600;
}
.capture-row .label {
color: #1e293b;
min-width: 82px;
}
.capture-row .value {
color: #1e293b;
flex: 1;
}
.capture-row-plate {
width: 100%;
margin-bottom: 8px;
}
.vehicle-plate {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 10px 32px;
box-sizing: border-box;
background: #f5d000;
border: 3px solid #fff;
border-radius: 8px;
box-shadow: 0 0 0 2px #fff;
}
.vehicle-plate-text {
font-size: 28px;
font-weight: 700;
color: #000;
letter-spacing: 1px;
}
.vehicle-plate-dot {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
}
.vehicle-plate-dot-lt {
left: 6px;
top: 8px;
}
.vehicle-plate-dot-lb {
left: 6px;
bottom: 8px;
}
.vehicle-plate-dot-rt {
right: 6px;
top: 8px;
}
.vehicle-plate-dot-rb {
right: 6px;
bottom: 8px;
}
.capture-qrcode {
text-align: center;
}
.capture-id {
font-size: 16px;
font-weight: 700;
color: #1e293b;
margin-bottom: 6px;
word-break: break-all;
padding: 0 4px;
}
.capture-qr-wrap {
padding: 8px 0;
min-height: 200px;
}
.capture-qr-img {
width: 200px;
height: 200px;
display: inline-block;
}
.capture-desc {
font-size: 11px;
font-weight: 500;
color: #64748b;
margin-top: 4px;
padding-bottom: 8px;
}
</style>
```
- [ ] **Step 2: Commit**
```bash
git add miniprogram/components/db-ui/db-pound-qrcode-popup.vue
git commit -m "$(cat <<'EOF'
feat(miniprogram): add centered pound QR popup component.
EOF
)"
```
---
### Task 4: 改造出入库列表页
**Files:**
- Modify: `miniprogram/pages/db/inventory/list.vue`
- [ ] **Step 1: 替换卡片底部按钮**
将:
```vue
<view class="db-card__foot" @click.stop>
<db-btn size="small" type="primary" plain @click="viewRelated(item)">关联</db-btn>
<db-btn size="small" type="primary" @click="genWeighing(item)">生成地磅单</db-btn>
</view>
```
改为:
```vue
<view class="db-card__foot" @click.stop>
<db-btn size="small" type="primary" plain @click="viewRelated(item)">关联</db-btn>
<db-btn size="small" type="primary" @click="openPoundQr(item)">磅单二维码</db-btn>
</view>
```
- [ ] **Step 2: 在关联弹窗后增加磅单弹窗**
在 `</db-popup>`(关联弹窗)之后增加:
```vue
<db-pound-qrcode-popup
:show="poundQrOpen"
:inventory-id="poundQrId"
:snapshot="poundQrSnapshot"
@close="poundQrOpen = false"
@update:show="(v) => (poundQrOpen = v)"
/>
```
- [ ] **Step 3: script 调整**
1. 增加 import:
```js
import DbPoundQrcodePopup from '@/components/db-ui/db-pound-qrcode-popup.vue'
```
2. 增加状态:
```js
const poundQrOpen = ref(false)
const poundQrId = ref('')
const poundQrSnapshot = ref(null)
```
3. 增加方法:
```js
function openPoundQr(item) {
poundQrId.value = item?.id || ''
poundQrSnapshot.value = item || null
poundQrOpen.value = true
}
```
4. **删除**整个 `genWeighing` 函数(约 263–277 行)及对 `PAGE_PATHS.dbWeighingDetail` 的仅服务于该函数的用法(若 `PAGE_PATHS` 仍被 `goBack`/`viewItem` 使用则保留 import)。
- [ ] **Step 4: Commit**
```bash
git add miniprogram/pages/db/inventory/list.vue
git commit -m "$(cat <<'EOF'
feat(miniprogram): replace inventory list weigh-slip create with pound QR.
EOF
)"
```
---
### Task 5: 验收与图谱更新
**Files:**
- 无强制代码变更;若改了文件则跑 graphify
- [ ] **Step 1: 再跑纯函数测试**
```bash
cd miniprogram && node scripts/test_db_pound_qr.mjs
```
Expected: `test_db_pound_qr: ok`
- [ ] **Step 2: 微信开发者工具手工验收**
1. 打开「出入库管理」列表:卡片底部仅「关联」「磅单二维码」。
2. 点「磅单二维码」:深色遮罩居中弹窗,黄牌车牌、往来单位、物料(含吨)、有备注则显示、日期+单 ID、二维码、「出入库单标识(扫码核验)」;标题为入/出库磅单二维码。
3. 扫码内容 = 该行出入库单 `id`。
4. 「关联」、点卡片进详情行为不变。
5. 断网或屏蔽外网图床时仍能出二维码(本地 canvas)。
- [ ] **Step 3: 更新知识图谱(若本会话改了代码)**
```bash
./scripts/update-graph.sh frontend
```
或按仓库惯例对 miniprogram 目录执行对应 graphify 更新。
- [ ] **Step 4: 若 Step 3 有产物,单独 commit**
```bash
git add miniprogram/graphify-out graphify-out
git commit -m "$(cat <<'EOF'
chore: update graphify after inventory pound QR popup.
EOF
)"
```
(仅 add 实际变更的图谱路径。)
---
## Spec 覆盖自检
| Spec 要求 | 任务 |
|-----------|------|
| 去掉「生成地磅单」 | Task 4 |
| 「关联」+「磅单二维码」主色 | Task 4 |
| 居中深色弹窗、视觉对齐 PoundSlip | Task 3 |
| QR 内容 = inventory id | Task 2 + 3 |
| 本地生成、禁外网 QR 服务 | Task 2 |
| 列表快照 + get 补全 | Task 3 |
| 失败 toast 关弹窗 | Task 3 |
| 不改后端 | 全计划无 backend 文件 |
无 TBD / 无「类似 Task N」占位。