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

710 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 分享弹窗升级设计Share Modal Redesign
> 状态:待用户 review
> 作者:通过 brainstorming 协作完成
> 日期2026-06-11
> 项目TopFansuni-app + Vue 3`appid: __UNI__F199FF4`versionCode 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 天过期 |
| 失败 | `404`asset 不存在)、`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
```jsonc
"app-plus": {
"modules": {
"Share": {}, // 新增
"VideoPlayer": {},
"Camera": {},
"Speech": {},
"Push": {}
},
"distribute": {
"android": {
"schemes": ["topfans"] // 新增(数组类型,不是字符串)
},
"ios": {
"urltypes": [ // 新增
{ "urlschemes": ["topfans"] }
]
}
}
}
```
### 4.2 各平台 SDK 凭证
```jsonc
"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.share` 的 `provider` / `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 文档](https://uniapp.dcloud.net.cn/manifest/share.html) 当时的字段为准**。本 spec 不锁死字段结构,落地时以官方文档 + 各开放平台申请时的回调指引为准。
> ⚠️ **iOS Universal Links 后端配合**Universal Links 路径 `https://h5.topfans.com/uni-universallinks/` 下**必须**部署 `apple-app-site-association` 文件(无后缀的 JSON内容至少包含
> ```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.runtime` 在 **h5 / mp-weixin 端是 `undefined`**。需写为:
> ```js
> 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` + `onShareAppMessage`。`onShareAppMessage` 是页面级生命周期弹窗组件内无法注册需在承载页面 `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` 契约
```js
// 入参对象 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** `ShareModal` `ShareReportButtons` 透传进来
- **首次 pick** `useShare` 内部 `uni.downloadFile` 3 URL local path然后** local path** `composeKey`确保 nickname 变化 / 头像变化 / 远端图被替换都能触发重合成
- **后续 pick**弹窗内切换平台复用缓存的 local path`uni.downloadFile` 结果本身可缓存不再重复下载
- propsURL变化时 `watch` 触发 **缓存失效 + 清除 lastLocalPaths**下次 pick 重新 download
- **两级缓存****仅进程内有效**不做持久化
- **L1 内存缓存**`useShare` 闭包内 `Map`key = `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` / `qq`5s系统面板唤起快5s 足够
- `sinaweibo`15s微博 SDK 唤起 + 渲染慢5s 容易误判为失败导致假阳性
- **假阳性缓解**除超时外再叠加一个"app 失焦"判断——监听 `App.onHide`uni-app 应用生命周期对应底层 `plus.runtime` `pause` 事件触发即 **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
```js
if (l1Cache.has(composeKey) || l2ModuleCache.has(composeKey)) {
// 跳过 composeShareImage直接调 uni.share
return l1Cache.get(composeKey) || l2ModuleCache.get(composeKey);
}
```
> **不再使用 `uni.storage` 持久化**(理由见 § 5.3.1:临时文件路径进程结束后失效)。
`composeKey` `coverLocalPath + qrcodeLocalPath + avatarLocalPath + nickname` hash`useShare` 内部 download 后的 local path详见 § 5.3.1**任一变更则重合成**含用户换头像场景)。
### 5.5 错误码映射
| 错误源 | 用户提示 |
|--------|---------|
| `downloadFile` 失败 | "图片加载失败请重试" |
| canvas 导出失败 | "图片生成失败请重试" |
| `isApplicationExist` 返回 false | "请先安装 {微信/QQ/微博}" |
| `uni.share` fail errMsg `cancel` | **静默不弹 toast** |
| `uni.share` fail 其他 | "分享失败请稍后再试" |
**errMsg 平台差异**`cancel` 关键字需要做平台兼容
```js
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.png`、`shangxiahuadong.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. 监控埋点
接入项目现有的 `uniStatistics`manifest `enable: true`)。**保存图片走独立事件** `save_image_*`不与分享事件混报语义不同
```js
// 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` 一个依赖,无任何测试框架**。**已确认项目构建工具是 Vite**`frontend/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.js``vitest.config.js` 的 `setupFiles` 指定)顶部用 `globalThis.uni` 注入 stub具体测试用 `vi.mocked(uni.xxx).mockResolvedValueOnce(...)` 模拟回调。最小示例:
```js
// 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.js` 的 `define` 里注入。
### 11.2 关键单元用例
`image-compositor`
> **download 边界约定**`composeShareImage` **不负责 download**。所有远端图cover/qrcode/avatar由 `useShare` 在调用前 `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
- `composing``sharing`uni.share 调起)
- `sharing``done`success 回调)→ 2s 后自动 emit('close') 弹窗
- `sharing``error`fail 回调且非 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
- **二维码 mock**`useShare` 初始化时若 `import.meta.env.DEV` 为 true先调真实接口 → 失败则回退到 `/static/share/mock_qrcode.png`提交时一并放入1 个测试二维码图)
- **App 探测 mock**dev 环境下 `plus.runtime.isApplicationExist` 始终返回 `true`(避免开发机没装目标 App 时阻塞测试)
- **埋点 mock**`uni.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.2**`weixin.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.2**`qq.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.2**`sinaweibo.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 单独处理,本次不做)