# 分享弹窗升级设计(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` 在 `