diff --git a/docs/superpowers/specs/2026-06-11-share-modal-redesign-design.md b/docs/superpowers/specs/2026-06-11-share-modal-redesign-design.md new file mode 100644 index 0000000..957c138 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-share-modal-redesign-design.md @@ -0,0 +1,709 @@ +# 分享弹窗升级设计(Share Modal Redesign) + +> 状态:待用户 review +> 作者:通过 brainstorming 协作完成 +> 日期:2026-06-11 +> 项目:TopFans(uni-app + Vue 3,`appid: __UNI__F199FF4`,versionCode 109) + +## 1. 背景与目标 + +`frontend/pages/components/ShareModal.vue` 当前只暴露 **2 个**分享入口:"保存图片" + "微信好友"。本次升级需扩展到 **5 个分享平台 + 1 个保存图片** 共 6 个按钮,并打通"二维码水印"和"图片合成"两件目前缺失的能力。 + +**目标平台**:app-plus(iOS / 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 (容器:开关/布局/事件编排) │ +│ ├─ 渲染分享卡 DOM(已有逻辑迁移) │ +│ ├─ 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": "", + "UniversalLinks": "https://h5.topfans.com/uni-universallinks/" + }, + "qq": { + "appid": "" + }, + "sinaweibo": { + "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": [".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做周边,应援全免费" +// +// 返回 Promise,resolve 后得到 { 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×1334(iOS 分享面板对最大边有压缩,1334 在 iPhone 6 之后机型上不会触发压缩降级)。L0 已 fillRect 白底,若仍出现黑底可加 `uni.compressImage` 二次处理。 + +### 5.3.1 `useShare()` 初始化约定 + +- **生命周期**:`ShareModal.vue` 在 `