Просмотр исходного кода

docs: add implementation plan for inventory pound QR popup.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 месяц назад
Родитель
Сommit
a6e0c4481f
1 измененных файлов с 677 добавлено и 0 удалено
  1. 677 0
      docs/superpowers/plans/2026-09-24-mp-inventory-pound-qrcode-popup.md

+ 677 - 0
docs/superpowers/plans/2026-09-24-mp-inventory-pound-qrcode-popup.md

@@ -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」占位。