64 KiB
分享弹窗升级设计(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 (容器:开关/布局/事件编排) │
│ ├─ <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 当前用占位图,本设计要求后端出真实二维码。二维码内容必须编码分享人信息和分享端,用于二级传播追溯(接收端扫码后能从 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 内部):
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 最佳实践推荐异步版本- 当前业务场景(弹窗打开时调用)用户对延迟敏感,应优先用异步版本
后端生成逻辑(伪代码):
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「请先登录」并跳转到登录页(沿用项目现有登录跳转逻辑) |
直接渲染 <ShareModal>(其他调用方未来可能直接传 visible=true) |
useShare 初始化时检查登录态 |
不渲染 6 个按钮,弹窗内显示"登录后才能分享"提示 + 「去登录」按钮 |
未登录态弹窗 UI:
┌────────────────────────────────────┐
│ ✕ 分享藏品 │
├────────────────────────────────────┤
│ │
│ [图片预览区域 - 仍然渲染] │
│ │
│ │
│ ┌──────────────────────────────┐ │
│ │ 🔒 登录后才能分享 │ │
│ │ │ │
│ │ [ 去登录 ] [ 再看看 ] │ │
│ └──────────────────────────────┘ │
│ │
└────────────────────────────────────┘
- 保留图片预览(让用户看到要分享什么)
- 6 个分享按钮完全不渲染(不是 disabled,而是 v-if 移除,避免 DOM 探嗅)
- 「去登录」点击后 emit('close') + 跳转登录页
- 「再看看」点击后 emit('close')
后端校验:即便前端做了拦截,POST /share/track 也必须做服务端二次校验:
// 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
{
"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取。后端校验该用户存在且状态正常(未注销/封禁),否则返回 401system_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 天滚动清理机制(本期不做,由运营/法务后续提需求)
数据库表设计
-- 分享事件追踪表(落库 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
"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.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 文档 当时的字段为准。本 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 |
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。需写为: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 契约
// 入参对象 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在<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结果本身可缓存),不再重复下载 - props(URL)变化时
watch触发 缓存失效 + 清除 lastLocalPaths,下次 pick 重新 download
- 品牌文案初始化(新增):
useShare顶层初始化时调一次pickRandomSlogan()(见 § 5.3.2),结果缓存为闭包内常量currentSlogan- 整个弹窗会话内
currentSlogan不变(避免同一弹窗切换平台时文案跳变) - 弹窗关闭后下次再开 → 重新随机抽一条(用户每次打开看到不同文案的概率高,增加新鲜感)
composeShareImage(opts)调用时把slogan: currentSlogan传入,L4 层渲染时使用
- 两级缓存(仅进程内有效,不做持久化):
- L1 内存缓存(
useShare闭包内Map):key =composeKey(用 local path + slogan 算,见 § 5.4),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
- L1 内存缓存(
- 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.3.2 品牌文案随机选取
L4 文案从候选池中每个会话随机抽取一条(不是每次合成重抽),避免同一弹窗打开期间切换平台看到不同文案的诡异体验。
数据源(新增文件):
// frontend/utils/brand-slogans.js (新增)
export const BRAND_SLOGANS = [
{ brandLine: 'TOPFANS,让热爱被发现', brandDesc: 'AI做周边,应援全免费' },
{ brandLine: 'TOPFANS,星河璀璨', brandDesc: '为你喜爱的明星加油' },
{ brandLine: 'TOPFANS,你的应援主场', brandDesc: '百万粉丝在线互动' },
{ brandLine: 'TOPFANS,与你同频', brandDesc: '把热爱变成专属周边' },
{ brandLine: 'TOPFANS,热爱可抵岁月长', brandDesc: '一键定制你的专属应援' },
{ brandLine: 'TOPFANS,粉丝的小宇宙', brandDesc: '和同好一起为爱发电' },
{ brandLine: 'TOPFANS,让世界看见你', brandDesc: 'AI 让应援更有趣' },
{ brandLine: 'TOPFANS,为热爱加冕', brandDesc: '你的应援值得被收藏' }
// ... 共 8~12 条,运营/设计后续可调整
];
export function pickRandomSlogan() {
return BRAND_SLOGANS[Math.floor(Math.random() * BRAND_SLOGANS.length)];
}
useShare 集成(在 § 5.3.1 初始化约定中增加):
useShare在<script setup>顶层(首次挂载时)调用pickRandomSlogan()一次,缓存为闭包内变量currentSlogan- 整个弹窗会话期间
currentSlogan不变(不论切换多少次平台都复用同一条) - 弹窗关闭后下次再开 → 重新随机抽一条(用户感知到"每次打开文案可能不一样",增加新鲜感)
composeShareImage调用时把currentSlogan作为slogan字段传入
缓存键影响:composeKey 计算时必须把 slogan 也算进去(不同文案 → 不同合成图)。具体更新见 § 5.4。
手动换一句(YAGNI:本期不做):弹窗底部未来可加"换一句文案"按钮,调用 pickRandomSlogan() 重抽并触发 composeShareImage 重新合成。本期不实现,文案每会话稳定即可。
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:临时文件路径进程结束后失效)。
composeKey 用 coverLocalPath + qrcodeLocalPath + avatarLocalPath + nickname + slogan.brandLine + slogan.brandDesc 做 hash(useShare 内部 download 后的 local path + 当前会话的随机 slogan,详见 § 5.3.1 / § 5.3.2),任一变更则重合成(含用户换头像场景、含 slogan 重新随机场景)。
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(§ 13),故不匹配
return /cancel|取消/i.test(errMsg);
}
5.6 归因追踪调用流程
分享动作结束(success / fail / cancel 任一回调触发)后,调用追踪接口记录分享人 + 分享端:
用户 ShareActionBar useShare 后端追踪接口
│ │ │ │
│ 选完平台 │ │ │
│ uni.share │ │ │
│ 回调触发 │ │ │
│ │ │ │
│ │ │ 准备 payload │
│ │ │ - sharer_user_id (从 storage 取,登录态校验)
│ │ │ - system_type (uni.getSystemInfo())
│ │ │ - share_target (本次选的平台)
│ │ │ - result (success / cancel / fail_*)
│ │ │ - asset_id
│ │ │ - client_ts (Date.now())
│ │ │ │
│ │ │ POST /share/track
│ │ │────────────────►│
│ │ │ │ 校验登录态 + 写库 share_events
│ │ │◄────────────────│
│ │ │ { share_event_id } | 401 Unauthorized
│ │ │ (失败静默) │
关键约束:
- 追踪接口调用不影响弹窗关闭、toast 显示等用户感知行为。
uni.share回调触发后先走弹窗关闭 / toast 流程,再 fire-and-forget 调追踪接口(await 不阻塞 UI) - 取消分享(
cancel)也要上报(用于统计「分享被取消率」),仅result: 'cancel' - 失败同样上报(用于渠道失败率归因),按 § 5.5 错误码映射填
result - 保存图片 走同一接口,
share_target: 'save_image' sharer_user_id取值:从uni.getStorageSync('user').id取(项目现有登录逻辑已写 storage)。useShare内部在pick()入口再做一次非空校验(防 § 3.4 拦截被绕过)
登录态校验示例代码(位于 useShare 内部):
function pick(action) {
// 前端二次拦截:未登录不进入分享流程(兜底 § 3.4)
const userStr = uni.getStorageSync('user');
if (!userStr) {
uni.showToast({ title: '请先登录', icon: 'none' });
setTimeout(() => uni.navigateTo({ url: '/pages/login/login' }), 800);
return;
}
const userInfo = JSON.parse(userStr);
if (!userInfo?.id) {
uni.showToast({ title: '请先登录', icon: 'none' });
return;
}
// ... 后续 composing/sharing 流程
}
6. 图标资源
当前 ShareModal.vue 用了错误的占位图(wenhao.png、shangxiahuadong.png),新增以下资源到 frontend/static/share/:
资源文件尺寸按物理像素(设计稿 @2x),uni-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。落地时有两种走法:
- 简化图形 + 品牌色:用文字 "微信" "朋友圈" "QQ" "空间" "微博" 配合各自品牌色(微信绿 #07C160 / QQ 蓝 #1296DB / 微博橙 #E6162D)的简化形状(气泡/相机/铅笔),自行设计后提交设计 review
- 走法 B:若已获得品牌方书面授权(市场/法务确认),才允许使用官方 logo
本 spec 默认采用走法 1(简化图形 + 文字 + 品牌色),由设计师出图后再最终替换。
7. 错误处理分级
| 错误等级 | 触发条件 | 用户体验 | 是否上报 |
|---|---|---|---|
| L0 静默 | uni.share fail 且 errMsg 含 cancel |
不弹 toast,弹窗可继续点 | ❌ |
| L1 提示 | App 未安装/参数错误 | uni.showToast 显示 1.5s(toast 期间按钮可点) |
✅ |
| 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_*,不与分享事件混报(语义不同)。所有事件均带上分享人(user_id)和分享来源端系统类型(system_type)字段(已登录用户才有分享事件,见 § 3.4):
// useShare composable 初始化时一次性取到 systemType,后续所有事件复用(避免在事件回调里再 await)
const { platform: systemType } = await uni.getSystemInfo();
// 5 个分享平台共用
uni.report('share_action_click', {
asset_id: assetId,
target: 'weixin_friend' | 'weixin_moment' | 'qq' | 'qq_zone' | 'sinaweibo', // 仅 5 个 share target;save_image 走独立事件
user_id: currentUserId, // 已登录用户 ID(必填)
system_type: systemType // 预解析值:'android' / 'ios' / 'mp-weixin' / 'h5' 等
});
uni.report('share_result', {
asset_id: assetId,
target: 'weixin_friend' | 'weixin_moment' | 'qq' | 'qq_zone' | 'sinaweibo',
user_id: currentUserId,
system_type: systemType,
result: 'success' | 'cancel' | 'fail_app_missing' | 'fail_network' | 'fail_canvas' | 'fail_other',
duration_ms: elapsed
});
// 保存图片独立事件(target 字段在 save_image_* 中省略,事件名本身已区分)
uni.report('save_image_click', {
asset_id: assetId,
user_id: currentUserId,
system_type: systemType
});
// duration_ms: 从调用 uni.saveImageToPhotosAlbum 到回调止
uni.report('save_image_result', {
asset_id: assetId,
user_id: currentUserId,
system_type: systemType,
result: 'success' | 'fail_permission' | 'fail_already_saved' | 'fail_other',
duration_ms: elapsed
});
// 追踪接口调用结果(可选埋点:用于监控 share_events 落库失败率)
uni.report('share_track_result', {
share_event_id: shareEventId, // 后端返回的事件 ID
http_status: 200 | 401 | 4xx | 5xx,
duration_ms: elapsed
});
target字段约定:share_action_click/share_result的target字段仅包含 5 个 share target(weixin_friend/weixin_moment/qq_zone/sinaweibo)。保存图片行为通过独立事件save_image_*区分,不在target枚举里。
system_type取值时机:在useShare初始化时一次性await uni.getSystemInfo()拿到systemType,所有事件复用同一个值。不要在事件回调里再 await(避免每次都触发异步调用且字段值为 Promise 对象)。
通过这些事件可计算:
- 分享 CTR(
share_action_click/ 弹窗打开次数) - 各平台失败率(
share_result.result != 'success'占比) - canvas 成功率(iOS 黑底问题占比)
- 分享渠道分布(按
system_type分组:iOS 用户占比、Android 用户占比、微信小程序用户占比) - 人均分享次数(按
user_id分组聚合) - 追踪接口健康度(
share_track_result失败率)
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. 测试
10.0 前置:接入测试框架
项目当前 frontend/package.json 只有 vuex 一个依赖,无任何测试框架。已确认项目构建工具是 Vite(frontend/vite.config.js 存在,未发现 webpack/vue-cli 痕迹)。Vitest 与 Vite 原生兼容(共享配置、resolve.alias、transform 流水线),无需额外胶水代码。
接入步骤:
- 安装
vitest+@vue/test-utils+happy-dom(或 jsdom)作为 devDependencies frontend/vitest.config.js配置好 alias@指向frontend/(与 vite.config.js 保持一致)frontend/package.json增加脚本:"test:unit": "vitest run"、"test:watch": "vitest"- 在
frontend/.gitignore里加coverage/排除覆盖率产物
预计工作量 0.5 人天。没这一步 § 10.1 全部不成立。
10.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 | 见 § 10.3 |
Vitest 注入 uni 全局 stub(解决 happy-dom/jsdom 不带 uni 的问题):
在 frontend/test/setup.js(vitest.config.js 的 setupFiles 指定)顶部用 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.js的define里注入。
10.2 关键单元用例
image-compositor:
download 边界约定:
composeShareImage不负责 download。所有远端图(cover/qrcode/avatar)由useShare在调用前uni.downloadFile转本地,传入composeShareImage的都是本地路径。composeShareImage自身只在拿不到本地路径时使用占位图兜底,不触发任何网络 IO。
composeShareImage: 3 个本地路径全部有效 → 返回 tempFilePathcomposeShareImage: 任意本地路径缺失 → 使用占位图兜底,不抛错composeShareImage: nickname 为空 → 用首字母占位圆(取昵称首字符 ASCII 65~90 区间)composeShareImage: 合成图尺寸 = 750×1334 pxcomposeShareImage: 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状态 5s(weixin/qq)/ 15s(sinaweibo)无回调 → 强制idle+ L2 toastsharing状态收到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+slogan二次调用 → 跳过合成,复用lastComposedPath - 任意入参变化 → 重新合成(含
avatarLocalPath变化,即用户换头像场景) slogan字段变化(slogan 重抽) → 重新合成(composeKey 失效)
brand-slogans(独立测试组):
pickRandomSlogan()返回值必须是BRAND_SLOGANS数组中的某个元素(不能返回未定义值)pickRandomSlogan()连续调用 1000 次 → 至少覆盖 6 条不同 slogan(验证随机性,候选池 ≥ 8 条时的概率统计)BRAND_SLOGANS数组所有元素都包含brandLine和brandDesc两个字段(schema 校验)
composeShareImage 文案分支:
opts.slogan提供 → L4 层渲染slogan.brandLine+slogan.brandDescopts.slogan缺失 → 回退默认文案'TOPFANS,让热爱被发现'+'AI做周边,应援全免费'(不抛错)opts.slogan.brandLine为空字符串 → 跳过 brandLine 行只渲染 brandDesc(容错)
10.3 手动验收 Checklist
功能
- 弹窗打开后,6 个按钮均可点击,无错位
- 微信好友:分享到文件传输助手/好友,能看到带二维码的合成图
- 朋友圈:发到自己朋友圈,能看到合成图 + 文字
- QQ 好友:发到 QQ 好友/群,能看到合成图
- QQ 空间:发到自己空间,能看到合成图
- 微博:发到微博(自己可见),能看到合成图
- 保存图片:相册中能找保存的图(与弹窗预览一致)
- 复制链接降级:连续失败 2 次后出现「复制链接」按钮,点击后系统剪贴板有正确 URL
异常路径
- 未装微信:toast「请先安装微信」,弹窗不关闭
- 用户在系统分享面板点取消:弹窗不关闭,不弹 toast
- 飞行模式下点击分享:toast「图片加载失败,请重试」
- 同一资产重新打开弹窗 3 次:只请求一次后端 QR code
视觉
- 6 个按钮在 iPhone 13 mini(5.4 寸)也能完整显示(最小目标机型)
- 合成图在微信聊天中不被压缩成马赛克
- 合成图右下角二维码可正常扫码
- iOS 真机手动验证合成图无黑底(E2E 暂不做,依赖 iPhone 8 / iPhone 13 真机目视确认)
性能
- 弹窗从打开到 6 个按钮可点击 ≤ 500ms(中端机如 iPhone X / 小米 8 实测基线;低端机不保证)
- 同一分享图二次调用合成耗时 ≤ 100ms(L1 内存缓存命中路径;L2 模块级缓存命中再 +50ms,差值主要来自 Map 查找)
11. Definition of Done
- 上述手动 checklist 全部 ✅
- 单元测试 100% 通过,
image-compositor行覆盖 100%(与 § 10.1 一致) - iPhone X(iOS 16+)和小米 12(Android 13)真机各 5 次分享无崩溃
- 上线后 24 小时 L1+L2 错误率不上升 > 0.3pp(基线对照:发布前 7 天均值)
manifest.json占位<TBD>全部替换为真实凭证
12. 待用户/后端提供的信息
- 微信开放平台移动应用 AppID(§ 12.2.1)
- QQ 互联移动应用 AppID(§ 12.2.2)
- 微博开放平台 AppID(§ 12.2.3)
- iOS Bundle ID
- Android 应用签名 SHA1(微信/QQ)/ MD5(微博)— 见 § 12.2.4 说明
- 后端
GET /api/v1/share/asset-qrcode/:assetId接口就绪 - 后端
POST /api/v1/share/track接口就绪(§ 3.5)+share_events表 migration 落地 - 后端
/share/track实现登录态校验(401 路径),与 § 3.4 一致
当前线上坏路径:
frontend/pages/components/ShareReportButtons.vue:75引用/static/icon/qrcode-placeholder.png,但该文件在仓库中不存在(已验证frontend/static/icon/目录里无此文件)。当前线上代码该路径已坏。本 spec 落地时同步处理:要么创建该占位图,要么改路径到/static/share/mock_qrcode.png(推荐后者,与 § 6 资源目录一致)。
12.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 即可,不影响生产代码。
12.2 各平台 AppID 申请路径
⚠️ 三平台 AppID 申请均需 1~7 个工作日审核,必须在打包前至少 1 周启动申请。可并行申请三个平台以节省时间。
12.2.1 微信开放平台 AppID
| 项 | 值 |
|---|---|
| 入口 URL | https://open.weixin.qq.com/ |
| 账号要求 | 重新注册(与公众号/小程序账号不通用) |
| 资质 | 仅企业(营业执照 + 对公账户验证,1~3 工作日);个人开发者无法申请移动应用 |
| 审核周期 | 1~7 个工作日 |
| 价格 | 免费 |
申请流程:
- 登录后进入"管理中心 → 移动应用 → 创建移动应用"
- 填写:应用官网(需 ICP 备案的域名)、应用名称、简介、图标(28×28 / 108×108)、Android 包名 + 签名 SHA1、iOS Bundle ID
- 提交审核
拿到后要配:
- 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..."
12.2.2 QQ 互联 AppID
| 项 | 值 |
|---|---|
| 入口 URL | https://connect.qq.com/ |
| 账号要求 | 用 QQ 登录即可(QQ 互联开发者账号) |
| 资质 | 个人可(身份证 + 银行卡验证);企业开发者更稳 |
| 审核周期 | 1~3 个工作日 |
| 价格 | 免费 |
申请流程:
- 进入"应用管理 → 创建应用 → 移动应用"
- 填写:应用名称、简介、图标、Android 包名 + 签名 SHA1、iOS Bundle ID、上线后必填应用市场下载链接
- 提交审核
拿到后要配:
- Android SHA1 / iOS Bundle ID 与打包配置严格一致
- 开放平台"回调域"填
https://h5.topfans.com(虽然纯图片分享不走 OAuth,但平台默认要求填)
最终填到 manifest § 4.2:qq.appid = "10..."(QQ 互联后台叫 "APP ID",数字格式)
12.2.3 微博开放平台 AppID
| 项 | 值 |
|---|---|
| 入口 URL | https://open.weibo.com/ |
| 账号要求 | 用已实名认证的微博账号登录(个人认证即可) |
| 资质 | 个人可 |
| 审核周期 | 1~5 个工作日 |
| 价格 | 免费 |
申请流程:
- 进入"微连接 → 移动应用 → 创建应用"
- 填写:应用名称、图标、简介、类别、Android 包名 + 签名 MD5(不是 SHA1)、iOS Bundle ID
- "高级信息" 填 Bundle ID / 包名
- 提交审核
拿到后要配:
- 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)
12.2.4 签名算法三平台对比
| 平台 | Android 签名算法 | 取法 |
|---|---|---|
| 微信 | SHA1 | keytool -list -v -keystore xxx.keystore | grep SHA1 |
| SHA1 | keytool -list -v -keystore xxx.keystore | grep SHA1 |
|
| 微博 | MD5 | keytool -list -v -keystore xxx.keystore | grep MD5 |
⚠️ 同一个 keystore 同时算出 SHA1 + MD5 即可,不需要为不同平台准备多个 keystore。
12.2.5 三平台申请前 checklist
| 准备项 | 微信 | 微博 | 备注 | |
|---|---|---|---|---|
| 营业执照(企业) | ✅ 必需 | ⭕ 可选 | ❌ 个人可 | 决定走企业或个人通道 |
| 备案域名 | ✅ 必需 | ❌ | ❌ | 准备好 ICP 备案号 |
| Android keystore | ✅ | ✅ | ✅ | 同一个 keystore 即可 |
| iOS Bundle ID | ✅ | ✅ | ✅ | 全平台共用,唯一 |
| 应用图标 (108×108) | ✅ | ✅ | ✅ | 设计师出 |
| 应用截图 | ✅ 5 张 | ✅ 3~5 张 | ✅ 3~5 张 | 提前准备 |
12.2.6 ❌ 常见错路
| 需求 | 错路 | 正确方向 |
|---|---|---|
| 微信 AppID | 微信公众号后台 / 微信小程序后台 | 微信开放平台 open.weixin.qq.com |
| QQ AppID | QQ 公众平台 | QQ 互联 connect.qq.com |
| 微博 AppID | 微博广告平台 | 微博开放平台 open.weibo.com |
| 复用现有 AppID | 现有 H5 / 小程序的 AppID 直接用 | 必须重新申请移动应用 AppID |
13. 不做的事(YAGNI)
- 不做海报编辑/二次裁剪
- 不做"先选平台 → 再选好友"的二级分享面板(直接走
uni.share系统面板) - 不做分享到抖音/小红书/钉钉(这些平台没有稳定的 uni-app 集成)
- 不做分享后回跳游戏内领奖励的回调(独立需求,后续单独 spec)
- 不做 h5 端的分享(
uni.share在 h5 下行为不一致,h5 用 Web Share API 单独处理,本次不做)