Moodji 文档中心
首页
下载App
  • 简体中文
  • 繁體中文
  • English
  • 日本語
  • 한국어
首页
下载App
  • 简体中文
  • 繁體中文
  • English
  • 日本語
  • 한국어

结果分享闭环 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: 让工具结果卡片带上可扫的二维码,并把「分享出去」和「扫码回来」两端都变成可测量的埋点。

Architecture: 改的是两个共用组件 —— ResultShareCard.vue(卡片视觉,加二维码)与 AssessmentResult.vue(按钮与埋点)。二维码内容由纯函数 utils/shareLink.ts 生成,可单测。回流识别放在 utils/events.ts,由工具页 pages/tools/[slug].vue 在挂载时调用一次。20 个接入 AssessmentResult 的工具自动获得全部改动。

Tech Stack: Nuxt 3 + Vue 3 <script setup> + TypeScript · qrcode@^1.5.4(已在依赖里)· html-to-image(经 utils/captureNode.ts)· Vitest 2(environment: 'node',只测纯函数)· @vercel/analytics 的 track

Spec: docs/afit/specs/2026-09-17-result-share-loop.md

Global Constraints

  • 隐私红线:分数/等级/日期/任何用户输入绝不能进 URL。二维码 URL 只允许一个参数 s=qr。分数只出现在图片里。
  • 埋点绝不能影响工具本身:一律走 utils/events.ts 的 safeTrack(内部 try/catch 静默吞掉;isDevelopmentMode() 下不发)。
  • 二维码必须是朴素黑白方码,纠错级别 M。禁止使用 qr-code-styling(微信长按识别兼容差)。
  • 导出参数:utils/captureNode.ts 用 pixelRatio: 1.5。二维码 CSS 尺寸 84px,生成的 dataURL 宽度 336px(4×,缩放后仍清晰)。
  • 五种语言:新增的每个 i18n key 必须同时补齐 web/i18n/lang/ 下的 en.json / zh.json / zh-TW.json / ja.json / ko.json。
  • 测试范围:只对纯函数写 Vitest 单测(仓库现状:utils/events.ts 等依赖 window 的模块无单测,不在本期引入测试基建改造)。
  • 所有路径相对 web/ 目录。

与规格的一处有意偏离

规格 §5.2 写「基础 URL 走 utils/seo.ts 的 getCanonicalUrl()」。实现改为取当前页面的 window.location.origin + pathname,原因:工具页当前 URL 本身就是它的 canonical,直接用可避免重新推导语言前缀(getCanonicalUrl 需要传入不带语言前缀的 path + 当前语言 + 默认语言,在组件里再推一遍反而更容易错)。若将来 AssessmentResult 会出现在非 canonical 路径上,需回头修正。


Task 1: 分享链接纯函数 + 单测

Files:

  • Create: web/utils/shareLink.ts
  • Create: web/__tests__/unit/shareLink.test.ts
  • Modify: web/vitest.config.ts:13(coverage include 数组)

Interfaces:

  • Consumes: 无

  • Produces:

    • SHARE_PARAM: 's'、SHARE_VALUE: 'qr'
    • buildShareUrl(currentUrl: string): string —— 输入任意完整 URL,输出剥掉 query/hash、补尾斜杠、且只带 ?s=qr 的分享 URL
    • isShareArrival(search: string): boolean —— 输入 location.search,判断是否由二维码而来
  • [ ] Step 1: 写失败的测试

// web/__tests__/unit/shareLink.test.ts
import { describe, it, expect } from 'vitest'
import { SHARE_PARAM, SHARE_VALUE, buildShareUrl, isShareArrival } from '../../utils/shareLink'

describe('buildShareUrl — 二维码里编码的 URL', () => {
  it('should append ?s=qr to a clean tool URL', () => {
    expect(buildShareUrl('https://www.moodji.net/zh/tools/dass-21/')).toBe(
      'https://www.moodji.net/zh/tools/dass-21/?s=qr'
    )
  })

  it('should add the trailing slash when missing', () => {
    expect(buildShareUrl('https://www.moodji.net/tools/focus-assessment')).toBe(
      'https://www.moodji.net/tools/focus-assessment/?s=qr'
    )
  })

  // 隐私红线:任何已有参数都必须被丢弃,绝不能把分数带进二维码
  it('should drop every existing query param, including a score-like one', () => {
    expect(buildShareUrl('https://www.moodji.net/zh/tools/dass-21/?score=42&ch=abc')).toBe(
      'https://www.moodji.net/zh/tools/dass-21/?s=qr'
    )
  })

  it('should drop the hash fragment', () => {
    expect(buildShareUrl('https://www.moodji.net/zh/tools/dass-21/#result')).toBe(
      'https://www.moodji.net/zh/tools/dass-21/?s=qr'
    )
  })

  it('should not double-append when the input already carries s=qr', () => {
    expect(buildShareUrl('https://www.moodji.net/zh/tools/dass-21/?s=qr')).toBe(
      'https://www.moodji.net/zh/tools/dass-21/?s=qr'
    )
  })

  it('should return an empty string for an unparseable input instead of throwing', () => {
    expect(buildShareUrl('not a url')).toBe('')
  })

  it('should expose the param names it uses', () => {
    expect(`${SHARE_PARAM}=${SHARE_VALUE}`).toBe('s=qr')
  })
})

describe('isShareArrival — 落地页是否来自二维码', () => {
  it('should detect the param as the only one', () => {
    expect(isShareArrival('?s=qr')).toBe(true)
  })

  it('should detect the param among others', () => {
    expect(isShareArrival('?utm_source=x&s=qr')).toBe(true)
  })

  it('should return false when absent', () => {
    expect(isShareArrival('?utm_source=x')).toBe(false)
  })

  it('should return false for an empty search string', () => {
    expect(isShareArrival('')).toBe(false)
  })

  it('should not match a different value', () => {
    expect(isShareArrival('?s=app')).toBe(false)
  })

  it('should not match a param that merely ends with s', () => {
    expect(isShareArrival('?ns=qr')).toBe(false)
  })
})
  • [ ] Step 2: 运行测试,确认失败

Run: cd web && npx vitest run __tests__/unit/shareLink.test.ts Expected: FAIL —— Failed to resolve import "../../utils/shareLink"

  • [ ] Step 3: 写最小实现
// web/utils/shareLink.ts
/**
 * 分享二维码链接。
 *
 * 🔴 隐私红线:分数只进图片,绝不进 URL。链接会被转发、缓存、进浏览器历史与
 * 服务端日志,而图片是用户自己决定发给谁的。因此这里**先丢掉输入 URL 上的所有
 * 参数**,再只补一个 `s=qr` —— 即使调用方传进来一个带分数的地址也无法泄露。
 */
export const SHARE_PARAM = 's'
export const SHARE_VALUE = 'qr'

export function buildShareUrl(currentUrl: string): string {
  let url: URL
  try {
    url = new URL(currentUrl)
  } catch {
    // 拿不到合法 URL 时返回空串,由调用方决定不渲染二维码——绝不抛错打断结果页。
    return ''
  }
  const path = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`
  return `${url.origin}${path}?${SHARE_PARAM}=${SHARE_VALUE}`
}

export function isShareArrival(search: string): boolean {
  if (!search) return false
  return new URLSearchParams(search).get(SHARE_PARAM) === SHARE_VALUE
}
  • [ ] Step 4: 运行测试,确认通过

Run: cd web && npx vitest run __tests__/unit/shareLink.test.ts Expected: PASS(13 个用例)

  • [ ] Step 5: 把新模块纳入覆盖率统计

web/vitest.config.ts 第 13 行:

      include: ['utils/reactionNorms.ts', 'utils/shareLink.ts'],

Run: cd web && npm run coverage(必须跑全量套件,不要带单个文件路径 —— coverage 阈值是全局的,只跑一个文件会让 include 里其余模块记 0%,进而以「未达 80%」失败退出,那是假失败) Expected: 退出码 0,且 utils/shareLink.ts 四项指标均 ≥80%

  • [ ] Step 6: 提交
git add web/utils/shareLink.ts web/__tests__/unit/shareLink.test.ts web/vitest.config.ts
git commit -m "feat(share): 分享链接纯函数 — 只允许 s=qr,丢弃一切既有参数

隐私红线落在函数里而不是调用方:即使传进来带分数的 URL 也无法泄露。"

Task 2: 回流埋点 share_arrival

Files:

  • Modify: web/utils/events.ts(文件末尾追加;safeTrack 在第 144 行,currentToolSlug 在第 130 行)
  • Modify: web/pages/tools/[slug].vue(<script setup> 内新增 onMounted)

Interfaces:

  • Consumes: Task 1 的 isShareArrival(search: string): boolean
  • Produces: reportShareArrival(): void

为什么这个任务没有单测:reportShareArrival 读 window.location 并调用 track,而仓库的 Vitest 跑在 environment: 'node',utils/events.ts 现有函数同样无单测。可测的判定逻辑已经在 Task 1 的 isShareArrival 里测过。本任务靠预览环境实测验证(Task 6)。

  • [ ] Step 1: 在 utils/events.ts 末尾追加上报函数

在文件顶部的 import 区补上:

import { isShareArrival } from './shareLink';

在文件末尾追加:

// 扫二维码进来的人。微信/QQ 打开外链会剥掉 referrer,这批人在 Analytics 里
// 混在「直接访问」里(2026-09 实测 CN 流量 89% 无来源),只能靠 URL 上的
// s=qr 把他们认出来。去重口径与 tool_calculate 一致:同一次页面浏览只报一次。
let reportedArrivalPath = '';

export function reportShareArrival() {
  if (typeof window === 'undefined') return;
  if (!isShareArrival(window.location.search)) return;

  const slug = currentToolSlug();
  if (!slug) return;

  const path = window.location.pathname;
  if (reportedArrivalPath === path) return;
  reportedArrivalPath = path;

  safeTrack('share_arrival', { slug, locale: getCurrentLocale() });
}
  • [ ] Step 2: 在工具页挂载时调用

web/pages/tools/[slug].vue 的 <script setup> 中,在已有 import 之后加入:

import { onMounted } from 'vue'
import { reportShareArrival } from '~/utils/events'

onMounted(() => {
  reportShareArrival()
})

若该文件已从 vue 引入其他具名导出,把 onMounted 并入现有 import,不要重复 import。

  • [ ] Step 3: 确认类型与构建通过

Run: cd web && npx nuxi typecheck 2>&1 | tail -20 Expected: 无与 events.ts / [slug].vue 相关的报错。 若仓库未配置 typecheck,改跑 cd web && npm run build 2>&1 | tail -5,Expected: 构建成功。

  • [ ] Step 4: 提交
git add web/utils/events.ts web/pages/tools/[slug].vue
git commit -m "feat(share): 新增 share_arrival 埋点,把扫码回流从直接访问里认出来"

Task 3: 卡片内嵌二维码

Files:

  • Modify: web/components/tools/common/ResultShareCard.vue(script 第 1–3 行 import 区;props 第 24–50 行;template 底部 bottom-row 第 154–161 行;<style scoped> 末尾)

Interfaces:

  • Consumes: 无(URL 由 Task 4 以 prop 传入)

  • Produces: ResultShareCard 新增可选 prop qrUrl?: string 与 qrHint?: string。两者都为空时不渲染二维码块,卡片版式与现在完全一致。

  • [ ] Step 1: 在 script 顶部引入依赖并生成 dataURL

ResultShareCard.vue 第 1–3 行改为:

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import QRCode from 'qrcode'
import RecommendationList from './RecommendationList.vue'

在 props 定义(withDefaults(defineProps<{...}>(), {...}))的类型里追加两个可选字段:

  qrUrl?: string
  qrHint?: string

并在默认值对象里追加:

  qrUrl: '',
  qrHint: '',

在 props 之后追加生成逻辑:

// 二维码用 dataURL 而不是外链 <img src>:导出走 html-to-image,跨域外链图会被
// 静默丢掉,导出的卡片上就会少一块。dataURL 没有这个问题。
// 朴素黑白方码 + 纠错 M:微信长按识别对花式码兼容差。
// 宽度 336 = 展示尺寸 84px 的 4 倍,导出时 pixelRatio 1.5 缩放后仍清晰。
const qrDataUrl = ref('')

watch(
  () => props.qrUrl,
  async (url) => {
    if (!url) {
      qrDataUrl.value = ''
      return
    }
    try {
      qrDataUrl.value = await QRCode.toDataURL(url, {
        errorCorrectionLevel: 'M',
        margin: 1,
        width: 336,
        color: { dark: '#1B1B1B', light: '#FFFFFF' },
      })
    } catch {
      // 生成失败就不显示二维码,卡片其余部分照常——分享图不能因此打不开。
      qrDataUrl.value = ''
    }
  },
  { immediate: true }
)
  • [ ] Step 2: 在底部行渲染二维码

ResultShareCard.vue 的 bottom-row 块(第 154–161 行)替换为:

    <div class="bottom-row">
      <span class="foot-line">
        <span class="foot-label">{{ dateLabel }}</span><span class="date">{{ dateStr }}</span>
      </span>
      <span v-if="showSource" class="foot-line">
        <span class="foot-label">{{ sourceLabel }}</span><span class="watermark">{{ sourceNote }}</span>
      </span>
      <span v-if="qrDataUrl" class="qr-block">
        <span v-if="qrHint" class="qr-hint">{{ qrHint }}</span>
        <img :src="qrDataUrl" class="qr-img" alt="" />
      </span>
    </div>
  • [ ] Step 3: 加样式

在 <style scoped> 末尾追加:

.qr-block {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-left: auto;
}
.qr-hint {
  font-size: 12px;
  line-height: 1.3;
  max-width: 96px;
  opacity: 0.75;
}
.qr-img {
  width: 84px;
  height: 84px;
  border-radius: 6px;
  background: #fff;
  display: block;
}
@media (max-width: 380px) {
  .qr-hint { display: none; }
  .qr-img { width: 72px; height: 72px; }
}
  • [ ] Step 4: 构建确认不报错

Run: cd web && npm run build 2>&1 | tail -5 Expected: 构建成功(此时二维码还没有调用方传值,卡片应与改动前完全一致)。

  • [ ] Step 5: 提交
git add web/components/tools/common/ResultShareCard.vue
git commit -m "feat(share): 结果卡片支持内嵌二维码(dataURL、黑白方码、纠错 M)"

Task 4: 分享按钮、复制图片与 result_share 埋点

Files:

  • Modify: web/utils/events.ts(末尾追加)
  • Modify: web/components/tools/common/AssessmentResult.vue(script 第 1–8 行 import 区、第 57–70 行 saveImage、template 第 112–125 行按钮区、ResultShareCard 的 props 绑定)

Interfaces:

  • Consumes: Task 1 的 buildShareUrl;Task 3 的 qrUrl / qrHint props

  • Produces: reportResultShare(method: 'shared' | 'downloaded' | 'copied'): void

  • [ ] Step 1: 在 utils/events.ts 末尾追加分享上报

// 用户导出了一次结果卡片。
// ⚠️ method='shared' 的口径是「调起了系统分享面板」,不是「确实发出去了」:
// 用户在面板上点取消时浏览器抛 AbortError,saveOrShareNode 现有实现把它当作
// shared 返回,而 Web Share API 本身不告诉我们最终发给了谁,做不到更准。
// 读数时按上限理解;真实回流看 share_arrival。
export function reportResultShare(method: 'shared' | 'downloaded' | 'copied') {
  const slug = currentToolSlug();
  if (!slug) return;
  safeTrack('result_share', { slug, method, locale: getCurrentLocale() });
}
  • [ ] Step 2: 在 AssessmentResult.vue 引入依赖

第 1–8 行的 import 区改为:

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { saveOrShareNode, captureNodeToBlob, prewarmCapture } from '~/utils/captureNode'
import { triggerDownload } from '~/composables/useDownloadModal'
import { reportToolCalculate, reportResultShare } from '~/utils/events'
import { buildShareUrl } from '~/utils/shareLink'
import ResultShareCard from './ResultShareCard.vue'
import RecommendationList from './RecommendationList.vue'

注:reportShareArrival 由 Task 2 的页面调用,不要在这里 import —— 上面这份 import 里刻意没有它。

  • [ ] Step 3: 计算二维码 URL 与提示文案

在 const sourceLabel = computed(...) 之后追加:

// 当前工具页的 URL 本身就是它的 canonical,直接用可避免在组件里重推语言前缀。
// buildShareUrl 会剥掉所有既有参数,因此分数不可能被带进二维码。
const shareUrl = ref('')
const qrHint = computed(() => t('result_share_qr_hint'))

onMounted(() => {
  shareUrl.value = buildShareUrl(window.location.origin + window.location.pathname)
})
  • [ ] Step 4: 分享成功后上报,并新增复制图片

第 57–70 行的 saveImage 替换为:

const imgCopied = ref(false)

async function saveImage() {
  if (!cardRef.value || busy.value) return
  busy.value = true
  try {
    const outcome = await saveOrShareNode(cardRef.value, 'moodji-result.png')
    reportResultShare(outcome)
    saved.value = true
    setTimeout(() => { saved.value = false }, 2500)
  } catch (error) {
    console.error('Failed to generate share image:', error)
  } finally {
    busy.value = false
  }
}

async function copyImage() {
  if (!cardRef.value || busy.value) return
  busy.value = true
  try {
    const blob = await captureNodeToBlob(cardRef.value)
    if (!blob) throw new Error('empty blob')
    // @ts-ignore - ClipboardItem 在旧 lib 类型里可能缺失
    await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
    reportResultShare('copied')
    imgCopied.value = true
    setTimeout(() => { imgCopied.value = false }, 2500)
  } catch (error) {
    console.error('Copy image failed, falling back to save:', error)
    try {
      const outcome = await saveOrShareNode(cardRef.value, 'moodji-result.png')
      reportResultShare(outcome)
    } catch (e2) {
      console.error('Save fallback failed:', e2)
    }
  } finally {
    busy.value = false
  }
}
  • [ ] Step 5: 改按钮文案并加复制按钮

template 第 117–120 行的保存按钮替换为:

      <button class="btn btn-save" :disabled="busy" @click="saveImage">
        <Icon :name="saved ? 'mdi:check' : 'mdi:share-variant'" class="btn-icon" />
        {{ saved ? t('result_saved') : t('result_share_image') }}
      </button>
      <button class="btn btn-save" :disabled="busy" @click="copyImage">
        <Icon :name="imgCopied ? 'mdi:check' : 'mdi:content-copy'" class="btn-icon" />
        {{ imgCopied ? t('result_copied') : t('result_copy_image') }}
      </button>
  • [ ] Step 6: 把二维码传给卡片

在 template 里 <ResultShareCard 的属性列表末尾(:description="description" 之后的任意位置)追加:

        :qr-url="shareUrl"
        :qr-hint="qrHint"
  • [ ] Step 7: 构建确认

Run: cd web && npm run build 2>&1 | tail -5 Expected: 构建成功。

  • [ ] Step 8: 提交
git add web/utils/events.ts web/components/tools/common/AssessmentResult.vue
git commit -m "feat(share): 分享结果按钮 + 复制图片 + result_share 埋点"

Task 5: 五语言文案

Files:

  • Modify: web/i18n/lang/en.json、zh.json、zh-TW.json、ja.json、ko.json

Interfaces:

  • Consumes: Task 3/4 用到的 key

  • Produces: result_share_image、result_copy_image、result_copied、result_share_qr_hint 四个 key ×5 语言

  • [ ] Step 1: 确认旧 key 的引用已消失

Run: cd web && grep -rn "result_save_image" components pages composables utils Expected: 无输出(Task 4 已替换掉唯一引用)。若仍有输出,先改完再继续。

  • [ ] Step 2: 在五份语言文件里新增 key

在每份文件中 result_save_image 所在位置附近加入(JSON 无序,放在相邻位置便于维护):

en.json:

  "result_share_image": "Share result",
  "result_copy_image": "Copy image",
  "result_copied": "Copied",
  "result_share_qr_hint": "Scan to test yourself",

zh.json:

  "result_share_image": "分享结果",
  "result_copy_image": "复制图片",
  "result_copied": "已复制",
  "result_share_qr_hint": "扫码测你的",

zh-TW.json:

  "result_share_image": "分享結果",
  "result_copy_image": "複製圖片",
  "result_copied": "已複製",
  "result_share_qr_hint": "掃碼測你的",

ja.json:

  "result_share_image": "結果をシェア",
  "result_copy_image": "画像をコピー",
  "result_copied": "コピー済み",
  "result_share_qr_hint": "スキャンして診断",

ko.json:

  "result_share_image": "결과 공유",
  "result_copy_image": "이미지 복사",
  "result_copied": "복사됨",
  "result_share_qr_hint": "스캔해서 검사하기",
  • [ ] Step 3: 删除已无引用的旧 key

从五份文件中删除 result_save_image 这一行。

  • [ ] Step 4: 校验 JSON 合法且五份 key 齐全
cd web && python3 -c "
import json
keys = ['result_share_image','result_copy_image','result_copied','result_share_qr_hint']
for L in ['en','zh','zh-TW','ja','ko']:
    d = json.load(open(f'i18n/lang/{L}.json', encoding='utf-8'))
    missing = [k for k in keys if k not in d]
    assert not missing, (L, missing)
    assert 'result_save_image' not in d, (L, 'old key still present')
    print(L, 'ok')
"

Expected: 五行 ok。

  • [ ] Step 5: 提交
git add web/i18n/lang/
git commit -m "feat(share): 分享结果/复制图片/扫码提示文案(5 语言)"

Task 6: 导出实测与上线验证

Files: 无代码改动(仅在发现问题时回到 Task 3/4 修)

Interfaces: 无

这一步不能省:html-to-image 是否把二维码画进图里,只能靠导出后肉眼看 —— 组件在页面上渲染正常不代表导出正常。

  • [ ] Step 1: 本地起服务并导出一张图
cd web && npm run dev

浏览器打开 http://localhost:3000/zh/tools/dass-21/,做完测评,点「分享结果」,保存下来的 PNG 用看图工具打开。

Expected:二维码完整出现在卡片右下角,边缘清晰不糊。 若二维码缺失 → 回到 Task 3 检查是否用了 dataURL(外链图会被丢弃)。

  • [ ] Step 2: 用手机扫导出的图

Expected:落地到 …/zh/tools/dass-21/?s=qr,页面正常打开。 若扫不出 → 回到 Task 3 把 .qr-img 尺寸从 84px 调到 96px 后重测。

  • [ ] Step 3: 真机在微信里长按识别

把图片发到微信,长按图片选「识别图中二维码」。 Expected:能识别并跳转。 若识别失败 → 确认用的是 qrcode 而非 qr-code-styling。

  • [ ] Step 4: 窄屏版式检查

浏览器开发者工具切到 375px 宽,查看结果卡片。 Expected:卡片不横向溢出,二维码不被裁切(≤380px 时提示文案自动隐藏)。

  • [ ] Step 5: 五语言各导出一次

逐个打开 /tools/、/zh/tools/、/zh-TW/tools/、/ja/tools/、/ko/tools/ 下的 dass-21,各导出一张。 Expected:无 i18n key 原样显示(如出现 result_share_qr_hint 字样即为漏配),文案不溢出卡片。

  • [ ] Step 6: 非心理类工具回归抽查

打开 /tools/bmi-calculator/ 与 /ja/tools/ffmi-calculator/,各导出一张。 Expected:卡片与导出均正常,二维码指向各自的工具页。

  • [ ] Step 7: 埋点验证(必须在预览环境,本地 dev 不发)

utils/events.ts 的 safeTrack 在 isDevelopmentMode() 下直接 return,所以本地点按钮不会产生任何事件。推送到分支拿 Vercel 预览 URL,在预览环境上:分享一次、复制一次、再用二维码扫一次落地。

隔日在 Vercel Analytics 用事件维度核对(筛选格式见 docs/afit/ops-website.md): Expected:result_share 出现 method=shared(或 downloaded)与 method=copied;share_arrival ≥1。

  • [ ] Step 8: 合并上线并记录
git push origin main

部署完成后用线上 URL 复验一次导出(仅信构建绿不够),并在 docs/afit/ops-website.md 的跟踪日志追加一条:上线日期、预测 #(30 天内 share_arrival ≥ 30,验收日 2026-10-01)。


Self-Review

1. 规格覆盖

规格章节对应任务
§5.1 二维码(位置/dataURL/黑白方码/≥72px)Task 3
§5.2 URL 只带 s=qr、不带分数Task 1(纯函数强制)+ Task 4(调用)
§5.3 result_share / share_arrival 两个事件 + 去重Task 2、Task 4
§5.4 按钮文案 + 复制图片 + 删旧 keyTask 4、Task 5
§5.5 共用组件影响 20 个工具Task 3/4 天然覆盖;Task 6 Step 6 做回归抽查
§6 验证表 7 项Task 6 的 7 个 Step 一一对应
§7 预测与验收Task 6 Step 8 记录

2. 占位符扫描:无 TBD/TODO;每个代码步骤都给了完整可粘贴的代码;每个验证步骤都给了命令与预期。

3. 类型一致性:buildShareUrl / isShareArrival / reportShareArrival / reportResultShare 的名称与签名在定义处与使用处一致;saveOrShareNode 返回的 'shared' | 'downloaded' 与 reportResultShare 的入参联合类型对齐,'copied' 仅由复制路径传入。