# 分享弹窗升级设计(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` 预留接口**(前端调用代码不需变更)。 **新增目标 - 分享归因追踪**(用户 2026-06-16 提出): - 每次分享动作需记录**当前分享人**的身份标识 `sharer_user_id`(**仅限已登录用户**,未登录用户不能分享,见 § 3.4) - 每次分享动作需记录**分享来源端**(`system_type`:android / ios / mp-weixin / h5 等,见 § 3 枚举表) - 这两个字段用于后续追溯分享链路、二级传播、渠道归因(例:用户 A 在 iOS 上分享给用户 B,B 扫码注册/打开后可识别出"来自 A") **关键决策**(用户确认): | 决策点 | 结论 | |--------|------| | 覆盖平台 | 微信好友 / 朋友圈 / QQ 好友 / QQ 空间 / 微博(5 个) | | 按钮布局 | 单行 6 个图标(保存图片 + 5 分享),`flex: 1` 等分宽度 | | 保存图片 | 保留为第 6 个按钮 | | 技术路线 | `uni.share` API(统一跨端,预留 mp-weixin 接入时验证) | | 分享内容 | **带二维码的合成图** | | 图片合成 | **前端 Canvas 离屏合成**(方案 A) | | 二维码来源 | **后端接口**(消除 `ShareReportButtons.vue:75` 的占位图路径 `/static/icon/qrcode-placeholder.png`,该文件实际不存在,见 § 12) | | 分享人身份字段 | **仅** `sharer_user_id`(强制登录;不提供匿名/share_code 兜底) | | 分享端识别 | 客户端读取 `uni.getSystemInfo()`(`android` / `ios` / `h5` / `mp-weixin` 等 uni-app 标准值) | | 归因数据落点 | 新增后端接口 + 同步埋点(详见 § 3.5、§ 8) | | 未登录用户处理 | **禁止分享**:弹窗上"保存图片"和 5 个分享按钮全部禁用,顶部显示"登录后才能分享"提示并提供登录入口(详见 § 3.4) | ## 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` 当前用占位图,本设计要求后端出真实二维码。**二维码内容必须编码分享人信息和分享端**,用于二级传播追溯(接收端扫码后能从 URL 参数识别"来自谁"和"从哪个平台")。 | 项 | 值 | |----|----| | Method & Path | `GET /api/v1/share/asset-qrcode/:assetId` | | Query 参数 | `sharer_user_id`(**必填**,登录用户 ID;与 § 3.4 登录门槛联动,未登录前端不调此接口) | | | `system_type`(**必填**,系统/平台类型,决定分享来源端的运行环境,值见下表) | | | `share_target`(可选,候选值 `weixin_friend` / `weixin_moment` / `qq` / `qq_zone` / `sinaweibo` / `save_image`;前端弹窗打开时已知要分享的目标,但本期**不传**,默认所有二维码按 `weixin_friend` 编码,后续按需扩展) | | 鉴权 | 需要登录态 | #### `system_type` 取值(分享来源端的系统类型) > 该字段标识**用户发起分享时所在的客户端运行环境**,用于二级归因中区分渠道。值取自 `uni.getSystemInfo()`,**按需扩展**,后端字段类型 `varchar(32)` 不强制 enum。 | 取值 | 含义 | uni-app 对应 | 优先级 | |------|------|--------------|--------| | **`android`** | Android 原生 App(app-plus 打包) | `platform === 'android'` | 高 | | **`ios`** | iOS 原生 App(app-plus 打包) | `platform === 'ios'` | 高 | | **`mp-weixin`** | 微信小程序 | `platform === 'mp-weixin'` | 高 | | **`mp-alipay`** | 支付宝小程序 | `platform === 'mp-alipay'` | 中 | | **`mp-baidu`** | 百度小程序 | `platform === 'mp-baidu'` | 中 | | **`mp-toutiao`** | 字节跳动小程序(抖音/头条) | `platform === 'mp-toutiao'` | 中 | | **`mp-lark`** | 飞书小程序 | `platform === 'mp-lark'` | 低 | | **`mp-qq`** | QQ 小程序 | `platform === 'mp-qq'` | 中 | | **`mp-kuaishou`** | 快手小程序 | `platform === 'mp-kuaishou'` | 低 | | **`mp-xhs`** | 小红书小程序 | `platform === 'mp-xhs'` | 低 | | **`h5`** | 移动端 H5 页面(含普通浏览器、WebView 内嵌页) | `platform === 'h5'` | 中 | | **`app-plus`** | uni-app app-plus 通用兜底(Android/iOS 未能识别时) | `platform === 'app-plus'` | 兜底 | | **`other`** | 上述都未覆盖的新平台/调试场景 | 兜底 | 兜底 | > **关键说明**: > - 本期产品**实际覆盖**只有 `android` / `ios` / `mp-weixin`(其中 mp-weixin 待后续小程序上线后启用,见 § 4.4)。其他值仅在 schema 层预留 > - `android` 与 `ios` 由当前 app-plus 客户端上报;`mp-weixin` 待 § 4.4 接入小程序时启用 > - `system_type` 与 `share_target` 是**正交**维度:前者是"分享来源端"(客户端类型),后者是"分享目标渠道"(微信好友/QQ/微博等)。例如「在 Android 上分享到微信好友」= `system_type=android, share_target=weixin_friend」 | 响应 | `200 { qrcode_url: string, expires_at: number }`(Unix 秒,签发后 7 天过期) | | 失败 | `400`(缺 `sharer_user_id` / `system_type`)、`404`(asset 不存在)、`5xx`(降级) | | 二维码内容 | `https://h5.topfans.com/asset/{assetId}?from={sharer_user_id}&s={system_type}` | | | - `from`:分享人用户 ID(短参数名 `from`,节省扫码密度) | | | - `s`:分享来源端的系统类型(短参数名 `s`;值为 `android` / `ios` / `mp-weixin` 等,见 § 3 枚举表) | | | h5 落地页解析这两个参数后: | | 1. 展示资产详情 | | 2. 调埋点 `qr_scan_landing`(带 `from` / `s` / `asset_id`) | | 3. 若用户未登录 + 跳登录页,登录成功回跳后**预填**邀请码 `from` 到注册请求中(**本期不做**,仅预留 URL 参数) | | 缓存 | 同一 `(assetId, sharer_user_id, system_type)` 三元组会话内只请求一次(前端 `ref` 缓存)。三元组任一变更 → 重新请求 | | 续签策略 | `expires_at - now < 24h` 时下次开弹窗**预热**请求(不阻塞 UI)。过期不阻断分享,弹窗打开仍能用缓存的 URL | | 失败兜底 | 弹窗 UI 用占位图,toast 提示"二维码生成失败,分享图可能无水印" | **与 image-compositor 协作**:`composeShareImage` 接收一个**已下载到本地的** `qrcodeLocalPath`(经过 `uni.downloadFile`),不再关心来源是后端还是占位图。 ### 3.3 二维码请求示例 **前端调用**(`useShare` 内部): ```js async function fetchQrcode(assetId) { // 1. 取登录用户信息(同步 OK,storage 读取不阻塞 UI) const userInfo = JSON.parse(uni.getStorageSync('user') || '{}'); // 2. 取系统类型(异步,避免阻塞 UI 线程) const { platform } = await uni.getSystemInfo(); // 3. 调后端二维码接口 const res = await uni.request({ url: `/api/v1/share/asset-qrcode/${assetId}`, method: 'GET', data: { sharer_user_id: userInfo.id, system_type: platform } }); return res.data; // { qrcode_url, expires_at } } ``` > **为什么用 `uni.getSystemInfo()` 而不是 `uni.getSystemInfoSync()`**: > - `getSystemInfoSync` 在低端机上可能阻塞 UI 主线程数十毫秒(特别是冷启动首次调用时) > - `getSystemInfo` 是异步 Promise API,不阻塞 UI;现代 uni-app 最佳实践推荐异步版本 > - 当前业务场景(弹窗打开时调用)用户对延迟敏感,应优先用异步版本 **后端生成逻辑**(伪代码): ```go func (h *Handler) GetAssetQrcode(c *gin.Context) { assetID := c.Param("assetId") sharerUserID := c.Query("sharer_user_id") // 必填,未填返 400 systemType := c.Query("system_type") // 必填,未填返 400 // 1. 校验资产存在 // 2. 拼接目标 URL:https://h5.topfans.com/asset/{assetId}?from={sharerUserID}&s={systemType} // 3. 用 qrcode 库生成 PNG,转 CDN URL // 4. 写缓存 share:qrcode:{assetId}:{sharerUserID}:{systemType} → qrcode_url,TTL 7d // 5. 返回 { qrcode_url, expires_at } } ``` **后端缓存键设计**: - Redis key 格式:`share:qrcode:{asset_id}:{sharer_user_id}:{system_type}` - TTL:7 天(与 `expires_at` 一致) - 命中后直接返回缓存的 `qrcode_url`,不重新生成 - 不同 sharer 分享同一个 asset → 生成不同二维码(因为 `from` 参数不同) > **后端可选优化(YAGNI:本期不做)**:用短链服务把 `https://h5.topfans.com/asset/12345?from=678&s=android` 缩短为 `https://t.fans/a8K2` 之类的短链,进一步降低二维码密度。本期直接用完整 URL,落地页跳转用 query 参数解析。 ### 3.4 未登录用户禁止分享(登录门槛) **结论**:用户必须登录后才能触发任何分享动作(保存图片 + 5 个分享按钮)。这是产品侧对 2026-06-16 反馈的最终决策:**只登录用户才能分享**,不做匿名 / 临时分享码兜底。 **为什么不做匿名分享**: - 归因数据质量:匿名用户无法追溯到具体账号,渠道归因统计价值低 - 业务诉求:分享行为本身是用户主动传播行为,登录门槛可筛掉机器人/脚本刷量 - 隐私更简单:无需设计 `share_code` 生成 / 存储 / 清理机制 **前端处理**: | 触发点 | 检查项 | 未登录时行为 | |--------|--------|------------| | 用户点击"分享"按钮(`ShareReportButtons.vue:handleShare`) | `uni.getStorageSync('user')` 是否存在且能解析出有效 `user_id` | 不打开弹窗,弹 toast「请先登录」并跳转到登录页(沿用项目现有登录跳转逻辑) | | 直接渲染 ``(其他调用方未来可能直接传 `visible=true`) | `useShare` 初始化时检查登录态 | 不渲染 6 个按钮,弹窗内显示"登录后才能分享"提示 + 「去登录」按钮 | **未登录态弹窗 UI**: ``` ┌────────────────────────────────────┐ │ ✕ 分享藏品 │ ├────────────────────────────────────┤ │ │ │ [图片预览区域 - 仍然渲染] │ │ │ │ │ │ ┌──────────────────────────────┐ │ │ │ 🔒 登录后才能分享 │ │ │ │ │ │ │ │ [ 去登录 ] [ 再看看 ] │ │ │ └──────────────────────────────┘ │ │ │ └────────────────────────────────────┘ ``` - 保留图片预览(让用户看到要分享什么) - 6 个分享按钮**完全不渲染**(不是 disabled,而是 v-if 移除,避免 DOM 探嗅) - 「去登录」点击后 emit('close') + 跳转登录页 - 「再看看」点击后 emit('close') **后端校验**:即便前端做了拦截,`POST /share/track` 也必须做服务端二次校验: ```go // share track handler // 注:TrackShareRequest 中 SharerUserId 定义为 int64(值类型,非指针) func (h *Handler) TrackShare(ctx context.Context, req *TrackShareRequest) (*TrackShareResponse, error) { // 二次拦截:从 JWT token / context 取当前用户 ID 与请求中传入的 SharerUserId 比对 currentUserID := auth.GetUserIDFromContext(ctx) if currentUserID == 0 { return nil, codex.NewError(codex.ErrUnauthorized, "请先登录后再分享") } if req.SharerUserId != currentUserID { // 防止越权:前端篡改 user_id 上报他人 return nil, codex.NewError(codex.ErrForbidden, "分享人 ID 与当前登录用户不一致") } // ... 后续落库逻辑 } ``` - 缺 `sharer_user_id` 直接返回 `ErrUnauthorized`(业务码 +1xxxxx) - 不依赖前端拦截,前端漏洞不污染数据库 - 详见 § 3.5 接口契约(`sharer_user_id` 改为必填) ### 3.5 分享归因追踪接口(新增) 每次分享动作(无论成功 / 失败 / 取消)需调用后端接口记录**分享人**和**分享端**,用于渠道归因、二级传播追溯。**调用前必须已登录**(见 § 3.4)。 | 项 | 值 | |----|----| | Method & Path | `POST /api/v1/share/track` | | Content-Type | `application/json` | | 鉴权 | **强制**(缺 `sharer_user_id` 直接 401) | | 请求体 | 见下方 Request Body | | 响应 | `200 { topfans.common.BaseResponse base = 1; int64 share_event_id = 2; }`(`share_event_id` 为后端落库的事件 ID) | | 失败 | `401`(未登录)、`4xx`(参数错误,**前端不重试**)/ `5xx`(前端**静默失败**,不阻断分享流程,不弹 toast) | #### Request Body ```jsonc { "asset_id": 12345, // 必填,被分享的资产 ID "sharer_user_id": 678, // 必填,已登录用户 ID(前端从 storage 取) "system_type": "android" | "ios" | "mp-weixin" | "mp-alipay" | ..., // 必填,uni.getSystemInfo() "share_target": "weixin_friend" | "weixin_moment" | "qq" | "qq_zone" | "sinaweibo" | "save_image", // 必填,本次分享的目标渠道 "result": "success" | "cancel" | "fail_app_missing" | "fail_network" | "fail_canvas" | "fail_other", // 必填,分享结果 "client_ts": 1718500000000, // 必填,客户端时间戳(毫秒) "extra": { // 可选,扩展字段 "app_version": "1.0.0", "os_version": "Android 13", "device_id": "..." // 用于风控的去标识化设备指纹 } } ``` #### 字段补充说明 - **`sharer_user_id`**:**必填**,从 `uni.getStorageSync('user').id` 取。后端校验该用户存在且状态正常(未注销/封禁),否则返回 401 - **`system_type` 取值**:直接传 `uni.getSystemInfo()` 的值。uni-app 标准枚举包括 `android` / `ios` / `h5` / `mp-weixin` / `mp-alipay` / `mp-baidu` / `mp-toutiao` / `mp-lark` / `mp-qq` / `mp-kuaishou` / `mp-xhs` / `app-plus`。后端字段类型用 `varchar(32)`,不强制 enum(保留扩展性)。**完整取值表见 § 3** | - **写库 vs 异步队列**:本期**直接同步写库**(`share_events` 表)。如果未来 QPS 上升导致 DB 压力,可改为异步 MQ,本期不做 - **隐私**:已登录用户场景下不存在 PII 问题(`user_id` 本身就是业务标识);GDPR 合规要求下后端仍可保留 90 天滚动清理机制(**本期不做**,由运营/法务后续提需求) #### 数据库表设计 ```sql -- 分享事件追踪表(落库 share_events) CREATE TABLE IF NOT EXISTS share_events ( id BIGSERIAL PRIMARY KEY, asset_id BIGINT NOT NULL, -- 被分享的资产 sharer_user_id BIGINT NOT NULL, -- 分享人用户 ID(强制非空:见 § 3.4) system_type VARCHAR(32) NOT NULL, -- android / ios / mp-weixin / h5 ...(见 § 3 枚举表) share_target VARCHAR(32) NOT NULL, -- weixin_friend / qq / sinaweibo / save_image result VARCHAR(32) NOT NULL, -- success / cancel / fail_* client_ts BIGINT NOT NULL, -- 客户端时间戳(毫秒) server_ts BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT, extra JSONB DEFAULT '{}'::jsonb, -- 扩展信息(app_version / os_version / device_id) CONSTRAINT fk_share_events_sharer FOREIGN KEY (sharer_user_id) REFERENCES users(id) ON DELETE RESTRICT ); CREATE INDEX idx_share_events_asset_id ON share_events (asset_id); CREATE INDEX idx_share_events_sharer ON share_events (sharer_user_id); CREATE INDEX idx_share_events_system_type ON share_events (system_type); CREATE INDEX idx_share_events_server_ts ON share_events (server_ts DESC); -- 序列起始值预留(按 CLAUDE.md 数据库规范) CREATE SEQUENCE share_events_id_seq START WITH 10000; ``` > **设计变化**(对比初版):移除 `share_code` 列与相关约束/索引;`sharer_user_id` 改为 `NOT NULL`;新增 `FOREIGN KEY` 约束确保分享事件始终关联有效用户。 #### 客户端调用流程 - **触发时机**:`uni.share` 回调(success / fail)或 `uni.saveImageToPhotosAlbum` 回调后 - **前置校验**:`useShare` 内部 `pick()` 时先确认 `currentUserId` 非空(前端 § 3.4 二次拦截,避免误上报) - **失败兜底**:接口调用失败时**不弹 toast**、**不重试**、**不阻断**当前分享流程。最多在 console.warn 输出一行 - **批量上报**(**YAGNI:本期不做**):理论上可以攒批上报(10s 一次),本期先按"每次分享 = 一次 HTTP 请求"实现。后续如果出现 QPS 瓶颈再优化 - **节流保护**:单用户 1s 内最多上报 5 次(前端 `useShare` 内部维护计数器 + setTimeout 节流),防止异常情况下被刷 ## 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') // slogan: { brandLine: string, brandDesc: string }? 默认由 useShare 从 BRAND_SLOGANS 随机抽取一条(见 § 5.3.2) // // 返回 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 | `{slogan.brandLine}` + `{slogan.brandDesc}` | y=1200~1334 | ✅(在 L0 上叠加) | `slogan` 入参(由 useShare 从 `BRAND_SLOGANS` 随机抽取,见 § 5.3.2) | > **覆盖关系关键点**: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` 在 `