docs:分析功能设计文档
This commit is contained in:
parent
52a88162f4
commit
e5061e9449
709
docs/superpowers/specs/2026-06-11-share-modal-redesign-design.md
Normal file
709
docs/superpowers/specs/2026-06-11-share-modal-redesign-design.md
Normal file
@ -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 (容器:开关/布局/事件编排) │
|
||||||
|
│ ├─ <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做周边,应援全免费"
|
||||||
|
//
|
||||||
|
// 返回 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` 在 `<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
|
||||||
|
- **两级缓存**(**仅进程内有效**,不做持久化):
|
||||||
|
- **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/`:
|
||||||
|
|
||||||
|
> 资源文件尺寸按**物理像素**(设计稿 @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**。落地时有两种走法:
|
||||||
|
> 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.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_*`,不与分享事件混报(语义不同):
|
||||||
|
|
||||||
|
```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 code(Vite 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` 状态 5s(weixin/qq)/ 15s(sinaweibo)无回调 → 强制 `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 mini(5.4 寸)也能完整显示(最小目标机型)
|
||||||
|
- 合成图在微信聊天中不被压缩成马赛克
|
||||||
|
- 合成图右下角二维码可正常扫码
|
||||||
|
- **iOS 真机手动验证合成图无黑底**(E2E 暂不做,依赖 iPhone 8 / iPhone 13 真机目视确认)
|
||||||
|
|
||||||
|
**性能**
|
||||||
|
|
||||||
|
- 弹窗从打开到 6 个按钮可点击 ≤ 500ms(中端机如 iPhone X / 小米 8 实测基线;低端机不保证)
|
||||||
|
- 同一分享图二次调用合成耗时 ≤ 100ms(L1 内存缓存命中路径;L2 模块级缓存命中再 +50ms,差值主要来自 Map 查找)
|
||||||
|
|
||||||
|
## 12. Definition of Done
|
||||||
|
|
||||||
|
- 上述手动 checklist 全部 ✅
|
||||||
|
- 单元测试 100% 通过,`image-compositor` 行覆盖 100%(与 § 11.1 一致)
|
||||||
|
- iPhone X(iOS 16+)和小米 12(Android 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 单独处理,本次不做)
|
||||||
Loading…
Reference in New Issue
Block a user