topfans/docs/superpowers/specs/2026-06-11-share-modal-redesign-design.md

42 KiB
Raw Blame History

分享弹窗升级设计Share Modal Redesign

状态:待用户 review 作者:通过 brainstorming 协作完成 日期2026-06-11 项目TopFansuni-app + Vue 3appid: __UNI__F199FF4versionCode 109

1. 背景与目标

frontend/pages/components/ShareModal.vue 当前只暴露 2 个分享入口:"保存图片" + "微信好友"。本次升级需扩展到 5 个分享平台 + 1 个保存图片 共 6 个按钮,并打通"二维码水印"和"图片合成"两件目前缺失的能力。

目标平台app-plusiOS / Android。H5 暂不支持,为后续接入 mp-weixin 预留接口(前端调用代码不需变更)。

关键决策(用户确认):

决策点 结论
覆盖平台 微信好友 / 朋友圈 / QQ 好友 / QQ 空间 / 微博5 个)
按钮布局 单行 6 个图标(保存图片 + 5 分享),flex: 1 等分宽度
保存图片 保留为第 6 个按钮
技术路线 uni.share API统一跨端预留 mp-weixin 接入时验证)
分享内容 带二维码的合成图
图片合成 前端 Canvas 离屏合成(方案 A
二维码来源 后端接口(消除 ShareReportButtons.vue:75 的占位图路径 /static/icon/qrcode-placeholder.png,该文件实际不存在,见 § 13

2. 架构与组件拆分

┌──────────────────────────────────────────────────────────────┐
│ ShareModal.vue (容器:开关/布局/事件编排)                    │
│   ├─ <SharePreviewCard>    渲染分享卡 DOM已有逻辑迁移   │
│   ├─ <ShareActionBar>      6 个按钮的纯展示组件              │
│   └─ useShare()            composable合成+分发+错误处理    │
└──────────────────────────────────────────────────────────────┘
单元 文件 职责 不做的事
ShareModal.vue frontend/pages/components/ShareModal.vue 控制 visible、承载布局、组合子组件、emit close 写分享/canvas 逻辑
SharePreviewCard.vue frontend/pages/components/SharePreviewCard.vue 渲染封面 + 头像 + 二维码 + 文案(纯模板) 导出图片、调 API
ShareActionBar.vue frontend/pages/components/ShareActionBar.vue 6 按钮单行 flex 布局(flex: 1 等分),emit('pick', action) 直接调 uni.share
useShare.js frontend/composables/useShare.js 状态机、合成调度、错误处理 写 UI
image-compositor.js frontend/utils/image-compositor.js 纯函数 composeShareImage(opts) → tempFilePath 不感知分享渠道

收益

  • image-compositor 是纯函数 → 易单元测试、不依赖 vue 运行时
  • useShare 后续可被其他场景复用(长按图片直接分享)
  • ShareActionBar 是纯展示 → 未来改成 2×3 grid 时只动它一个文件
  • 现有 320 行 ShareModal.vue 拆细后单文件 < 80 行

放置约定:上述 5 个文件全部按项目现有目录约定放置 —— pages/components/ 放 vue 组件(与 ShareReportButtons.vue 同层)、composables/ 放 composable已有 useDashboardData.js/useLaserMint.js 等)、utils/ 放纯函数工具(已有 assetImageHelper.js/avatarCache.js 等)。不创建新顶层目录

3. 二维码后端接口契约

ShareReportButtons.vue:75 当前用占位图,本设计要求后端出真实二维码。

Method & Path GET /api/v1/share/asset-qrcode/:assetId
Query 参数
鉴权 需要登录态
响应 200 { qrcode_url: string, expires_at: number }Unix 秒,签发后 7 天过期)
失败 404asset 不存在)、5xx(降级)
二维码内容 https://h5.topfans.com/asset/{assetId}h5 落地页,后续小程序上线后替换为短链)
缓存 同一 assetId 会话内只请求一次(前端 ref 缓存)
续签策略 expires_at - now < 24h 时下次开弹窗预热请求(不阻塞 UI。过期不阻断分享弹窗打开仍能用缓存的 URL
失败兜底 弹窗 UI 用占位图toast 提示"二维码生成失败,分享图可能无水印"

与 image-compositor 协作composeShareImage 接收一个已下载到本地的 qrcodeLocalPath(经过 uni.downloadFile),不再关心来源是后端还是占位图。

4. manifest.json 配置变更

4.1 启用 Share 模块与 URL Scheme

"app-plus": {
  "modules": {
    "Share": {},              // 新增
    "VideoPlayer": {},
    "Camera": {},
    "Speech": {},
    "Push": {}
  },
  "distribute": {
    "android": {
      "schemes": ["topfans"]    // 新增(数组类型,不是字符串)
    },
    "ios": {
      "urltypes": [           // 新增
        { "urlschemes": ["topfans"] }
      ]
    }
  }
}

4.2 各平台 SDK 凭证

"sdkConfigs": {
  "share": {
    "weixin": {
      "appid":         "<TBD: 微信开放平台移动应用 AppID>",
      "UniversalLinks": "https://h5.topfans.com/uni-universallinks/"
    },
    "qq": {
      "appid":         "<TBD: QQ 互联移动应用 AppID>"
    },
    "sinaweibo": {
      "appid":         "<TBD: 微博开放平台 AppID>"
    }
  }
}

4.2.1 uni.shareprovider / scene 字段映射

按钮 provider scene 备注
微信好友 weixin WXSceneSession 单聊/群聊
朋友圈 weixin WXSceneTimeline 朋友圈
QQ 好友 qq ""(空串) QQ SDK 用 type: 0 区分,本 spec 暂不依赖 type
QQ 空间 qq ""(空串) 同上
微博 sinaweibo undefined(不传该字段) 微博 SDK 无 scene 概念,传空串可能被 SDK 拒收

⚠️ 微信/QQ 的 AppID 必须在开放平台/互联申请「移动应用」(不能用现有小程序/公众号 AppID且必须配置 Android 应用签名 SHA1 和 iOS Universal Links否则 uni.share 调起后会被对方 App 拒绝。

⚠️ manifest schema 准确性问题:上述 sdkConfigs.share.* 结构按 uni-app 官方文档2024 版)的最小必填字段给出。但各 SDK 实际接入时可能需要 iOS / Android 子块(如 weixin 的 { appid, UniversalLinks, iOS: { appid }, Android: { appid, sha1 } })以适配两端差异。实施时需以 uni-app 官方 manifest 文档 当时的字段为准。本 spec 不锁死字段结构,落地时以官方文档 + 各开放平台申请时的回调指引为准。

⚠️ iOS Universal Links 后端配合Universal Links 路径 https://h5.topfans.com/uni-universallinks/必须部署 apple-app-site-association 文件(无后缀的 JSON内容至少包含

{
  "applinks": {
    "apps": [],
    "details": [{ "appIDs": ["<TeamID>.com.topfans.app"], "components": [{ "/": "/uni-universallinks/*", "comment": "match" }] }]
  }
}

需后端/运维在落地 manifest.json 前确认已部署该文件

⚠️ 微博 OAuth 路径澄清:纯图片分享(uni.share({provider: 'sinaweibo', imageUrl, ...})不需要 redirect_uri,那是微博 OAuth 登录场景(uni.login({provider: 'sinaweibo'}))才需要。本 spec 仅做图片分享,配置 redirect_uri。

4.3 平台未安装的探测

uni.share 的 fail 回调无法区分"用户取消"和"App 未安装",需前置探测

平台 Android pname iOS bundleid
微信 com.tencent.mm com.tencent.xinWeChat
QQ com.tencent.mobileqq com.tencent.mqq
微博 com.sina.weibo com.sina.weibo

通过 plus.runtime.isApplicationExist({ pname / bundleid }) 探测,未安装时 uni.showToast({ title: '请先安装微信' }) 直接返回,不调 uni.share

跨端判空plus.runtimeh5 / mp-weixin 端是 undefined。需写为:

function isAppInstalled(pname, bundleid) {
  if (typeof plus === 'undefined' || !plus.runtime) {
    return true; // 非 app-plus 端:跳过探测(小程序有自己的 wx.canIUse / h5 用 navigator
  }
  return new Promise(resolve => {
    // 5+ API 回调签名callback({ exist: boolean })
    plus.runtime.isApplicationExist({ pname, bundleid }, e => resolve(!!e.exist));
  });
}

4.4 后续接小程序的预留(YAGNI:本期不实施)

⚠️ 本节为预留/参考资料本期不实施、不实现、不进入 plan。移到此处仅为不丢失长期设计意图。

mp-weixin.appid: ""manifest 当前值)填上后,uni.share 在小程序端理论上自动用 wx.miniProgram.shareTo* API前端调用代码不需要改。这是当时推荐方案 A 的关键收益之一,但需要在 mp-weixin 真机/IDE 上实测验证uni-app 对 imageUrl + provider: 'weixin' 在小程序端的转译行为,文档与实际实现可能不一致)。

降级路径(实测后再决定):若发现 uni.share 在小程序端不支持 imageUrl-only 分享,则降级为 uni.showShareMenu + onShareAppMessageonShareAppMessage 是页面级生命周期,弹窗组件内无法注册,需在承载页面(如 asset-detail.vue)提供该钩子。具体 handoff 协议(getApp().globalData 还是 EventBus待接入时再设计。

5. 分享时序

5.1 状态机

         ┌────────┐  pick(action)
         │  idle  │ ────────────►┌────────────┐
         └───┬────┘               │ composing  │
             │ 弹窗关闭/取消      └──┬─────┬───┘
             │◄─────────────────────┘     │ 成功
             │                            ▼
             │                       ┌────────────┐
             │                       │  sharing   │ 失败
             │                       └──┬─────┬───┘ ◄──┐
             │                          │     │       │
             │                          │     ▼       │
             │                          │  ┌────────┐ │
             │                          │  │  done  │─┘
             │                          │  └────────┘  2s 后自动关闭弹窗
             │                          │
             │                          ▼
             │                       ┌────────┐
             │   pick(again) ────────►│ error  │ 用户再次点任意按钮
             │                       └────┬───┘
             │◄───────────────────────────┘ 回到 composing 重试
             │
             ▼
          ┌────────┐
          │  idle  │ (任何状态点关闭弹窗 / emit('close'))
          └────────┘
状态 UI 反馈
composing 半透明遮罩 + 菊花转圈,禁用 6 个按钮
sharing 系统分享面板已弹出,前端不强 loading避免双重弹窗
done uni.showToast('分享成功')2s 后自动关闭弹窗
error uni.showToast 错误文案,弹窗保持打开,用户可重试

5.2 端到端时序

阶段 A弹窗打开 + 拉 QR code

用户       ShareReportButtons  ShareModal   useShare       后端
 │             │                  │              │              │
 │ 点击[分享]  │                  │              │              │
 │─────────────►                  │              │              │
 │             │ showShareModal=t │              │              │
 │             │─────────────────►│              │              │
 │             │                  │ onMounted/   │              │
 │             │                  │ watch(visible)             │
 │             │                  │─────────────►│              │
 │             │                  │              │ GET /share/  │
 │             │                  │              │ asset-qrcode │
 │             │                  │              │─────────────►│
 │             │                  │              │◄─────────────│
 │             │                  │              │ qrcode_url   │
 │             │                  │ props.qrcodeUrl = qrcode_url
 │             │                  │ 渲染预览卡  │              │

阶段 B用户选平台 → 探测 → 合成 + 分享

关键顺序便宜操作(探测)先于昂贵操作(合成)。App 没装就根本不该进入 composing 状态,也省一次 downloadFile + canvas。

用户       ShareActionBar   useShare      image-compositor   uni.share   微信/QQ/微博
 │             │              │                 │              │            │
 │ 点击[微信]  │              │                 │              │            │
 │─────────────►              │                 │              │            │
 │             │ emit('pick') │                 │              │            │
 │             │─────────────►│                 │              │            │
 │             │              │ 探测 App 是否安装(plus.runtime)              │
 │             │              │  ↓ 未装 → setState(error)+toast+return       │
 │             │              │  ↓ 已装 ↓               │            │
 │             │              │ setState(composing)│            │            │
 │             │              │  (loading 效果由 composing 状态驱动 v-if)    │
 │             │              │                 │              │            │
 │             │              │ composeShareImage(opts)        │            │
 │             │              │────────────────►│              │            │
 │             │              │                 │ downloadFile │            │
 │             │              │                 │ (cover/qr/   │            │
 │             │              │                 │  avatar)     │            │
 │             │              │                 │ canvas draw  │            │
 │             │              │                 │ canvas.toTempFilePath      │
 │             │              │ ◄───────────────│              │            │
 │             │              │ tempFilePath    │              │            │
 │             │              │                                 │            │
 │             │              │ uni.share({provider, scene, imageUrl})            │
 │             │              │─────────────────────────────────►            │
 │             │              │                                 │  唤起 App  │
 │             │              │                                 │───────────►│
 │             │              │                                 │  等待回调  │
 │             │              │                                 │◄───────────│
 │             │              │ ◄───────────────────────────────│            │
 │             │              │ success / fail(errMsg)         │            │
 │             │              │ showToast() / emit('close')    │            │

5.3 composeShareImage 契约

// 入参对象 ComposeOpts
//   coverLocalPath:    string  本地封面临时文件路径(已由 useShare 调 uni.downloadFile 转换)
//   qrcodeLocalPath:   string  本地二维码临时文件路径(缺失时回退到 /static/share/mock_qrcode.png
//   avatarLocalPath:   string  本地头像临时文件路径(缺失时回退到 /static/square/gerenzhongxinkangpinkuang.png
//   nickname:          string  用户昵称(缺首字母时回退到 'T'
//   brandLine:         string? 默认 "TOPFANS让热爱被发现"
//   brandDesc:         string? 默认 "AI做周边应援全免费"
//
// 返回 Promiseresolve 后得到 { tempFilePath, width, height }
//   tempFilePath: 本地临时文件路径,可直接传给 uni.share / uni.saveImageToPhotosAlbum
//   width/height: 物理像素(不是 rpx
//
// 重要:本函数不发起任何网络 IO所有 *LocalPath 由 useShare 提前下载
export function composeShareImage(opts) { /* ... */ }

合成图层(统一画布 750×1334 px固定物理像素不使用 rpx

元素 位置y 范围px 覆盖底层 来源(本地路径,由 useShare 提前 uni.downloadFile 转好)
L0 750×1334 白底背景 整图 0~1334 固定 fillRect同时规避 iOS 离屏 canvas 黑底)
L1 750×1000 封面 顶部 0~1000 coverLocalPath aspectFill 缩放
L2 头像圆 (240×240 px) + "ID: {nickname}" 文字 y=720~960 (在 L1 上叠加) avatarLocalPath + nickname
L3 二维码 (240×240 px) + 边框背景 y=960~1200 (在 L1 上叠加) qrcodeLocalPath
L4 "TOPFANS让热爱被发现" + "AI做周边应援全免费" y=1200~1334 (在 L0 上叠加) 常量文案

覆盖关系关键点L2 / L3 都在 L1封面之上叠加绘制海报布局L4 在 L0 之上叠加。L1 顶部 0~1000 区域不被 L2 遮挡的部分y=0~720即为封面顶部净空。L3 底部 1200 处开始让位给 L4。

输出物理尺寸固定 750×1334iOS 分享面板对最大边有压缩1334 在 iPhone 6 之后机型上不会触发压缩降级。L0 已 fillRect 白底,若仍出现黑底可加 uni.compressImage 二次处理。

5.3.1 useShare() 初始化约定

  • 生命周期ShareModal.vue<script setup> 顶层调用一次 const { state, pick } = useShare({ ... })pick() 内临时创建 composable避免每次丢失缓存
  • 入参与 composeKey 约定重要:自洽设计
    • useShare 接收 props = { coverUrl, qrcodeUrl, avatarUrl, nickname }全部远程 URL),由 ShareModalShareReportButtons 透传进来
    • 首次 pickuseShare 内部 uni.downloadFile 把 3 个 URL 转 local path然后用 local pathcomposeKey(确保 nickname 变化 / 头像变化 / 远端图被替换都能触发重合成)
    • 后续 pick(弹窗内切换平台)复用缓存的 local pathuni.downloadFile 结果本身可缓存),不再重复下载
    • propsURL变化时 watch 触发 缓存失效 + 清除 lastLocalPaths,下次 pick 重新 download
  • 两级缓存仅进程内有效,不做持久化):
    • L1 内存缓存useShare 闭包内 Mapkey = composeKey(用 local path 算value = tempFilePath。弹窗打开期间切换平台直接命中。
    • L2 模块级缓存image-compositor.js 顶层 Map):跨 useShare 实例(同一进程内 ShareModal 多次 mount / 多个组件同时使用)共享。最多保留 20 条LRU 淘汰。
    • 不持久化原因uni.saveImageToPhotosAlbum / uni.share 使用的本地临时文件路径在 app 进程结束后失效,写入 uni.storage 在下次启动时拿到也是死路径。uni.storage 只适合存元数据(如 composeKey → 远端 URL不适合存文件路径
    • 查询顺序L1 → L2 → composeShareImage
  • 5s/15s 无回调兜底(按平台差异化)uni.share 调起后启动定时器,到时若仍在 sharing 状态(无 success / fail 回调),强制 setState('idle') + 弹 L2 toast"分享超时,请重试"。该机制与 § 9 iOS 偶发无回调问题对应。时长按 provider 区分
    • weixin / qq5s系统面板唤起快5s 足够)
    • sinaweibo15s微博 SDK 唤起 + 渲染慢5s 容易误判为失败导致假阳性)
    • 假阳性缓解:除超时外,再叠加一个"app 失焦"判断——监听 App.onHideuni-app 应用生命周期,对应底层 plus.runtimepause 事件)触发即 clearTimeout 定时器,避免用户正在系统分享面板上选好友时被强制重置。
    • app 回到前台兜底App.onShow 触发时若 state 仍为 sharing(说明回调一直没来),启动 8s 兜底定时器,到时强制重置(避免用户回到 app 后 state 永远卡在 sharing
  • 多组件复用ShareReportButtons.vue(当前调用方)未来可继续用同一 composable新增"长按图片直接分享"场景时再开一个 composable 实例即可。

5.4 跨分享复用合成图

useShare 内部维护两级进程内缓存(实现细节见 § 5.3.1

  • L1 内存缓存useShare 闭包内 Map<composeKey, tempFilePath>):弹窗打开期间切换平台直接命中
  • L2 模块级缓存image-compositor.js 顶层 Map<composeKey, tempFilePath>):跨 useShare 实例 / 多次 mount 复用,最多 20 条 LRU
if (l1Cache.has(composeKey) || l2ModuleCache.has(composeKey)) {
  // 跳过 composeShareImage直接调 uni.share
  return l1Cache.get(composeKey) || l2ModuleCache.get(composeKey);
}

不再使用 uni.storage 持久化(理由见 § 5.3.1:临时文件路径进程结束后失效)。

composeKeycoverLocalPath + qrcodeLocalPath + avatarLocalPath + nickname 做 hashuseShare 内部 download 后的 local path详见 § 5.3.1任一变更则重合成(含用户换头像场景)。

5.5 错误码映射

错误源 用户提示
downloadFile 失败 "图片加载失败,请重试"
canvas 导出失败 "图片生成失败,请重试"
isApplicationExist 返回 false "请先安装 {微信/QQ/微博}"
uni.share fail 且 errMsg 含 cancel 静默不弹 toast
uni.share fail 其他 "分享失败,请稍后再试"

errMsg 平台差异cancel 关键字需要做平台兼容:

function isUserCancel(errMsg = '') {
  // iOS: "user cancel" / "cancel"(英文)
  // Android: "用户取消" / "取消"(中文,部分 SDK 报英文 "cancel"
  // 注h5 端 navigator.share 报 "AbortError",但本 spec 不做 h5§ 14故不匹配
  return /cancel|取消/i.test(errMsg);
}

6. 图标资源

当前 ShareModal.vue 用了错误的占位图(wenhao.pngshangxiahuadong.png),新增以下资源到 frontend/static/share/

资源文件尺寸按物理像素(设计稿 @2xuni-app 在样式里用 rpx 标注显示尺寸。设计稿宽度基准 750rpx。

资源路径 用途 文件物理像素(@2x
/static/share/ic_wechat_friend.png 微信好友 192×192 px
/static/share/ic_wechat_moment.png 朋友圈 192×192 px
/static/share/ic_qq.png QQ 好友 192×192 px
/static/share/ic_qq_zone.png QQ 空间 192×192 px
/static/share/ic_weibo.png 微博 192×192 px
/static/share/ic_save_image.png 保存图片 192×192 px
/static/share/ic_loading.gif 合成中菊花(可选) 128×128 px

设计风格保持弹窗调性(白色线性图标 + 阴影),参考小红书/微信原生分享面板的视觉。

⚠️ 商标风险:微信 / QQ / 朋友圈 / QQ空间 / 微博 的官方 logo 是注册商标。不能直接用各平台官方 PNG。落地时有两种走法:

  1. 简化图形 + 品牌色:用文字 "微信" "朋友圈" "QQ" "空间" "微博" 配合各自品牌色(微信绿 #07C160 / QQ 蓝 #1296DB / 微博橙 #E6162D的简化形状气泡/相机/铅笔),自行设计后提交设计 review
  2. 走法 B:若已获得品牌方书面授权(市场/法务确认),才允许使用官方 logo

本 spec 默认采用走法 1(简化图形 + 文字 + 品牌色),由设计师出图后再最终替换。

7. 错误处理分级

错误等级 触发条件 用户体验 是否上报
L0 静默 uni.share fail 且 errMsg 含 cancel 不弹 toast弹窗可继续点
L1 提示 App 未安装/参数错误 uni.showToast 显示 1.5stoast 期间按钮可点)
L2 重试 网络/download 失败 toast 显示 1.5s 期间按钮可点toast 不阻塞toast 消失后弹窗保持,用户可点任意按钮自由重试
L3 兜底 canvas 导出失败 / 多次重试仍失败 toast "分享暂不可用" + 显示「复制链接」降级入口

「复制链接」降级L3单次弹窗会话内 L1+L2 失败累计 ≥ 2 次后,弹窗底部出现"复制链接"按钮,复制 https://h5.topfans.com/asset/{assetId} 到剪贴板,不依赖任何 SDK。关闭弹窗后计数器清零不跨弹窗累计L0 静默不计)。

8. 监控埋点

接入项目现有的 uniStatisticsmanifest 已 enable: true)。保存图片走独立事件 save_image_*,不与分享事件混报(语义不同):

// 5 个分享平台共用
uni.report('share_action_click', {
  asset_id: assetId,
  target:    'weixin_friend' | 'weixin_moment' | 'qq' | 'qq_zone' | 'sinaweibo',
  user_id:   currentUserId
});

uni.report('share_result', {
  asset_id:     assetId,
  target:       'weixin_friend' | 'weixin_moment' | 'qq' | 'qq_zone' | 'sinaweibo',
  result:       'success' | 'cancel' | 'fail_app_missing' | 'fail_network' | 'fail_canvas' | 'fail_other',
  duration_ms:  elapsed
});

// 保存图片独立事件
uni.report('save_image_click',  { asset_id: assetId, user_id: currentUserId });
//   duration_ms: 从调用 uni.saveImageToPhotosAlbum 到回调止
uni.report('save_image_result', {
  asset_id:    assetId,
  result:      'success' | 'fail_permission' | 'fail_already_saved' | 'fail_other',
  duration_ms: elapsed
});

通过这两条事件可计算:分享 CTR、各平台失败率、canvas 成功率iOS 黑底问题占比)。

9. iOS 平台特定坑

触发场景 规避
Universal Links 未配 iOS 13+ 微信 SDK 强制 § 4.2 已配,必须
uni.share 偶发无回调 系统分享面板被快速关闭 5s/15s 超时后强制 setState('idle') + L2 toast按 provider 区分weixin/qq 5s, sinaweibo 15s实现细节见 § 5.3.1
canvas 离屏黑底 透明 PNG 合成 先 fillRect 白底再 draw
iOS 剪贴板读取需用户授权 「复制链接」降级 uni.setClipboardData 写入不需要用户授权uni-app 已封装),但 iOS 14+ 读取 剪贴板会弹「允许粘贴」系统弹窗。写场景不受影响,本设计只是写剪贴板,无需额外处理

10. 灰度发布

简化为两级(弹窗升级不需要 5%/20% 这么细的灰度):

阶段 范围 验收指标 时长
内测 versionCode >= 110 内测包 5 平台各分享 5 次无崩溃 + 手动 checklist 全过 1~2 天
全量 100% 灰度发布 share_result 失败率fail_* 占比)较旧版本不上升 > 0.5pp 持续观察 7 天

回滚开关useShare 内部读取 uni.getStorageSync('feature_share_redesign')(默认 true),开关为 false 时回退到原 2 按钮逻辑,无需发版即可关停。

回退实现方式(避免双倍代码负担):ShareModal.vue 顶层根据开关值 computed 决定渲染新版 6 按钮 <ShareActionBar> 还是旧版 2 按钮内联模板。旧版 2 按钮逻辑仅保留内联模板 + 极简函数< 30 行),不抽组件。开关一关,新代码 100% dead codeVite tree-shaking + 死代码删除)。

11. 测试

11.0 前置:接入测试框架

项目当前 frontend/package.json 只有 vuex 一个依赖,无任何测试框架已确认项目构建工具是 Vitefrontend/vite.config.js 存在,未发现 webpack/vue-cli 痕迹。Vitest 与 Vite 原生兼容共享配置、resolve.alias、transform 流水线),无需额外胶水代码。

接入步骤:

  1. 安装 vitest + @vue/test-utils + happy-dom(或 jsdom作为 devDependencies
  2. frontend/vitest.config.js 配置好 alias @ 指向 frontend/(与 vite.config.js 保持一致)
  3. frontend/package.json 增加脚本:"test:unit": "vitest run""test:watch": "vitest"
  4. frontend/.gitignore 里加 coverage/ 排除覆盖率产物

预计工作量 0.5 人天。没这一步 § 11.1 全部不成立

11.1 测试分层

工具 覆盖
单元 Vitest image-compositor.js(纯函数,100% 行覆盖
单元 Vitest useShare.js 的状态机分支mock uni.share/uni.downloadFile
组件 @vue/test-utils ShareActionBar.vue 渲染 6 个按钮、点击抛 onPick
E2E uni-app 自动化 (automator) iOS 真机 / Android 真机 5 平台分享(后续迭代,本期不做)
手动 验收 checklist 见 § 11.3

Vitest 注入 uni 全局 stub(解决 happy-dom/jsdom 不带 uni 的问题):

frontend/test/setup.jsvitest.config.jssetupFiles 指定)顶部用 globalThis.uni 注入 stub具体测试用 vi.mocked(uni.xxx).mockResolvedValueOnce(...) 模拟回调。最小示例:

// frontend/test/setup.js
import { vi } from 'vitest';
if (!globalThis.uni) {
  globalThis.uni = {
    downloadFile: vi.fn(({ url }) => Promise.resolve({ tempFilePath: '/mock/' + url.split('/').pop() })),
    share:         vi.fn(() => Promise.resolve({ errMsg: 'share:ok' })),
    saveImageToPhotosAlbum: vi.fn(() => Promise.resolve()),
    showToast:     vi.fn(),
    getStorageSync: vi.fn(() => ''),
    setStorageSync: vi.fn(),
    report:        vi.fn()
  };
}

实施时如发现 globalThis.uni 被 Vite 编译时静态分析剔除uni-app 编译时会把 uni.xxx 替换成平台代码),需要改用 vi.stubGlobal('uni', { ... }) 或在 vitest.config.jsdefine 里注入。

11.2 关键单元用例

image-compositor

download 边界约定composeShareImage 不负责 download。所有远端图cover/qrcode/avataruseShare 在调用前 uni.downloadFile 转本地,传入 composeShareImage 的都是本地路径。composeShareImage 自身只在拿不到本地路径时使用占位图兜底,触发任何网络 IO。

  • composeShareImage: 3 个本地路径全部有效 → 返回 tempFilePath
  • composeShareImage: 任意本地路径缺失 → 使用占位图兜底,抛错
  • composeShareImage: nickname 为空 → 用首字母占位圆(取昵称首字符 ASCII 65~90 区间)
  • composeShareImage: 合成图尺寸 = 750×1334 px
  • composeShareImage: L0 白底已 fillRect → 输出图背景非透明(非黑底)

useShare(独立测试组):

  • useShare 调用前 cover/qrcode/avatar 任一 uni.downloadFile 失败 → 抛 L2 错误,不进入 composing
  • useShare 调用前 download 全部成功 → 进入 composing + composeShareImage + uni.share

状态机转换测试(覆盖 § 5.1 状态图):

  • idle 初始状态,pick() → 探测成功 → composing;探测失败 → error(不进入 composing
  • composingsharinguni.share 调起)
  • sharingdonesuccess 回调)→ 2s 后自动 emit('close') 弹窗
  • sharingerrorfail 回调且非 cancel
  • error → 再次 pick()composing(重试路径)
  • 任何状态 → emit('close')idle + 清理所有定时器
  • sharing 状态 5sweixin/qq/ 15ssinaweibo无回调 → 强制 idle + L2 toast
  • sharing 状态收到 App.onHide → 清除超时定时器(不重置状态)
  • sharing 状态收到 App.onShow(之前 onHide 过)→ 启动 8s 兜底定时器

useShare 业务分支(与状态机转换测试互补):

  • 选 weixin_friend → 探测 App 未装 → 抛 L1 错误,不调 uni.share
  • 选 weixin_friend → 探测已装 → 调 composeShareImage → 调 uni.share
  • uni.share 回调 errMsg 含 cancel → 静默,不弹 toast
  • 同一 coverLocalPath+qrcodeLocalPath+avatarLocalPath+nickname 二次调用 → 跳过合成,复用 lastComposedPath
  • 任意入参变化 → 重新合成(含 avatarLocalPath 变化,即用户换头像场景)

11.3 手动验收 Checklist

功能

  • 弹窗打开后6 个按钮均可点击,无错位
  • 微信好友:分享到文件传输助手/好友,能看到带二维码的合成图
  • 朋友圈:发到自己朋友圈,能看到合成图 + 文字
  • QQ 好友:发到 QQ 好友/群,能看到合成图
  • QQ 空间:发到自己空间,能看到合成图
  • 微博:发到微博(自己可见),能看到合成图
  • 保存图片:相册中能找保存的图(与弹窗预览一致)
  • 复制链接降级:连续失败 2 次后出现「复制链接」按钮,点击后系统剪贴板有正确 URL

异常路径

  • 未装微信toast「请先安装微信」弹窗不关闭
  • 用户在系统分享面板点取消:弹窗不关闭,不弹 toast
  • 飞行模式下点击分享toast「图片加载失败请重试」
  • 同一资产重新打开弹窗 3 次:只请求一次后端 QR code

视觉

  • 6 个按钮在 iPhone 13 mini5.4 寸)也能完整显示(最小目标机型)
  • 合成图在微信聊天中不被压缩成马赛克
  • 合成图右下角二维码可正常扫码
  • iOS 真机手动验证合成图无黑底E2E 暂不做,依赖 iPhone 8 / iPhone 13 真机目视确认)

性能

  • 弹窗从打开到 6 个按钮可点击 ≤ 500ms中端机如 iPhone X / 小米 8 实测基线;低端机不保证)
  • 同一分享图二次调用合成耗时 ≤ 100msL1 内存缓存命中路径L2 模块级缓存命中再 +50ms差值主要来自 Map 查找)

12. Definition of Done

  • 上述手动 checklist 全部
  • 单元测试 100% 通过,image-compositor 行覆盖 100%(与 § 11.1 一致)
  • iPhone XiOS 16+)和小米 12Android 13真机各 5 次分享无崩溃
  • 全量发布后 24 小时 L1+L2 错误率较内测不上升 > 0.3pp
  • manifest.json 占位 <TBD> 全部替换为真实凭证

13. 待用户/后端提供的信息

  • 微信开放平台移动应用 AppID§ 13.2.1
  • QQ 互联移动应用 AppID§ 13.2.2
  • 微博开放平台 AppID§ 13.2.3
  • iOS Bundle ID
  • Android 应用签名 SHA1微信/QQ/ MD5微博见 § 13.2.4 说明
  • 后端 GET /api/v1/share/asset-qrcode/:assetId 接口就绪

当前线上坏路径frontend/pages/components/ShareReportButtons.vue:75 引用 /static/icon/qrcode-placeholder.png,但该文件在仓库中不存在(已验证 frontend/static/icon/ 目录里无此文件)。当前线上代码该路径已坏。本 spec 落地时同步处理:要么创建该占位图,要么改路径到 /static/share/mock_qrcode.png(推荐后者,与 § 6 资源目录一致)。

13.1 前端 mock 策略(后端接口未就绪时)

13.1 前端 mock 策略(后端接口未就绪时)

为不阻塞前端开发,前端在 frontend/utils/share-mock.js 实现 mock

  • 二维码 mockuseShare 初始化时若 import.meta.env.DEV 为 true先调真实接口 → 失败则回退到 /static/share/mock_qrcode.png提交时一并放入1 个测试二维码图)
  • App 探测 mockdev 环境下 plus.runtime.isApplicationExist 始终返回 true(避免开发机没装目标 App 时阻塞测试)
  • 埋点 mockuni.report 在 dev 环境下不发送console.log 输出 payload 即可
  • 生产环境:所有 mock 分支用 if (import.meta.env.DEV) { ... } 包裹Vite 构建 production 时被 dead-code elimination 自动消除。统一用 import.meta.env.DEV(不用 MODE/NODE_ENV,避免不同环境变量在自定义 build mode 下不一致。

预计后端接口到位后删除 share-mock.js 即可,不影响生产代码。

13.2 各平台 AppID 申请路径

⚠️ 三平台 AppID 申请均需 1~7 个工作日审核,必须在打包前至少 1 周启动申请。可并行申请三个平台以节省时间。

13.2.1 微信开放平台 AppID

入口 URL https://open.weixin.qq.com/
账号要求 重新注册(与公众号/小程序账号不通用
资质 仅企业(营业执照 + 对公账户验证1~3 工作日);个人开发者无法申请移动应用
审核周期 1~7 个工作日
价格 免费

申请流程

  1. 登录后进入"管理中心 → 移动应用 → 创建移动应用"
  2. 填写:应用官网(需 ICP 备案的域名、应用名称、简介、图标28×28 / 108×108、Android 包名 + 签名 SHA1、iOS Bundle ID
  3. 提交审核

拿到后要配

  • iOS注册 Universal Links 域名(与 manifest § 4.2 UniversalLinks: "https://h5.topfans.com/uni-universallinks/" 一致),并部署 apple-app-site-association(见 § 4.2 模板)
  • Android用打包 keystore 算 SHA1 — keytool -list -v -keystore xxx.keystore 取 SHA1 填到开放平台"应用签名"

最终填到 manifest § 4.2weixin.appid = "wx..."

13.2.2 QQ 互联 AppID

入口 URL https://connect.qq.com/
账号要求 用 QQ 登录即可QQ 互联开发者账号)
资质 个人可(身份证 + 银行卡验证);企业开发者更稳
审核周期 1~3 个工作日
价格 免费

申请流程

  1. 进入"应用管理 → 创建应用 → 移动应用"
  2. 填写应用名称、简介、图标、Android 包名 + 签名 SHA1、iOS Bundle ID、上线后必填应用市场下载链接
  3. 提交审核

拿到后要配

  • Android SHA1 / iOS Bundle ID 与打包配置严格一致
  • 开放平台"回调域"填 https://h5.topfans.com(虽然纯图片分享不走 OAuth但平台默认要求填

最终填到 manifest § 4.2qq.appid = "10..."QQ 互联后台叫 "APP ID",数字格式)

13.2.3 微博开放平台 AppID

入口 URL https://open.weibo.com/
账号要求 用已实名认证的微博账号登录(个人认证即可)
资质 个人可
审核周期 1~5 个工作日
价格 免费

申请流程

  1. 进入"微连接 → 移动应用 → 创建应用"
  2. 填写应用名称、图标、简介、类别、Android 包名 + 签名 MD5(不是 SHA1、iOS Bundle ID
  3. "高级信息" 填 Bundle ID / 包名
  4. 提交审核

拿到后要配

  • Android 签名 MD5与微信/QQ 的 SHA1 不同keytool -list -v -keystore xxx.keystore | grep MD5
  • iOS Universal Links微博 SDK 也要求 iOS 13+ 配 Universal Links
  • 微博后台"授权回调页"字段必填(填 https://h5.topfans.com 即可,不影响纯图片分享——那是 OAuth 登录场景才用)

最终填到 manifest § 4.2sinaweibo.appid = "..."(微博后台叫 "App Key"不是 AppID,按 uni-app 规范统一写 appid

13.2.4 签名算法三平台对比

平台 Android 签名算法 取法
微信 SHA1 keytool -list -v -keystore xxx.keystore | grep SHA1
QQ SHA1 keytool -list -v -keystore xxx.keystore | grep SHA1
微博 MD5 keytool -list -v -keystore xxx.keystore | grep MD5

⚠️ 同一个 keystore 同时算出 SHA1 + MD5 即可,不需要为不同平台准备多个 keystore。

13.2.5 三平台申请前 checklist

准备项 微信 QQ 微博 备注
营业执照(企业) 必需 可选 个人可 决定走企业或个人通道
备案域名 必需 准备好 ICP 备案号
Android keystore 同一个 keystore 即可
iOS Bundle ID 全平台共用,唯一
应用图标 (108×108) 设计师出
应用截图 5 张 3~5 张 3~5 张 提前准备

13.2.6 常见错路

需求 错路 正确方向
微信 AppID 微信公众号后台 / 微信小程序后台 微信开放平台 open.weixin.qq.com
QQ AppID QQ 公众平台 QQ 互联 connect.qq.com
微博 AppID 微博广告平台 微博开放平台 open.weibo.com
复用现有 AppID 现有 H5 / 小程序的 AppID 直接用 必须重新申请移动应用 AppID

14. 不做的事YAGNI

  • 不做海报编辑/二次裁剪
  • 不做"先选平台 → 再选好友"的二级分享面板(直接走 uni.share 系统面板)
  • 不做分享到抖音/小红书/钉钉(这些平台没有稳定的 uni-app 集成)
  • 不做分享后回跳游戏内领奖励的回调(独立需求,后续单独 spec
  • 不做 h5 端的分享(uni.share 在 h5 下行为不一致h5 用 Web Share API 单独处理,本次不做)