Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1010 lines
37 KiB
Markdown
1010 lines
37 KiB
Markdown
# 分享重构(去 SDK 化 + 剪贴板指引 + 系统分享兜底)Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 把微信/QQ/抖音分享统一改为「剪贴板+指引窗 → 系统分享」,不依赖任何分享 SDK,内容对齐原工程(文本统一 `title\ndescription`、图片不降级),契约/DTO 零改动。
|
||
|
||
**Architecture:** 沿用现有"能力层发事件 → entry 容器弹 UI → 一次性 resultEvent 回投"模式,新增第二个弹窗 `ShareGuideDialog`(渠道选定后选动作:打开App/用系统分享/取消)。`ShareProvider` 重写为:组装内容→写剪贴板→弹指引窗→按动作执行 `openLink`(best-effort) / `systemShare`(可靠兜底),仅微信乐观回传 `sharesuccess`。
|
||
|
||
**Tech Stack:** HarmonyOS ArkTS / ArkUI;`@ohos.pasteboard`(文本/PIXELMAP)、`@kit.ShareKit` `systemShare`、`@kit.AbilityKit` `bundleManager.canOpenLink`/`openLink`、`@kit.ImageKit` `createImageSource`、平台层 `Downloader`。
|
||
|
||
**约定:**
|
||
- 每个任务的硬验证 = `devecocli build` 输出 `BUILD SUCCESSFUL`(**本仓库 `devecocli` 无 test 子命令**,单元测试只能在 DevEco Studio 内运行,故 CLI 阶段以 build 编译通过为门禁,行为类验证靠真机/模拟器)。
|
||
- ArkTS 严格模式:禁 `Record`/无类型对象字面量、跨层只依赖接口、`@Concurrent` 不传函数。
|
||
- 每个任务结束即 `git commit`(中文信息,注明对应契约/计划条目)。
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
| 文件 | 责任 | 动作 |
|
||
|---|---|---|
|
||
| `common/src/main/ets/event/ShareEvents.ets` | 分享事件名 + 载荷 DTO | 改:加 `SHOW_GUIDE` + `ShareGuideRequest`/`ShareGuideResult` |
|
||
| `common/Index.ets` | common 公共导出 | 改:导出新增两个 interface |
|
||
| `entry/src/main/module.json5` | 模块配置 | 改:`querySchemes` 加 `mqqapi`/`snssdk1128` |
|
||
| `feature_capabilities/src/main/ets/share/ShareText.ets` | 分享文本拼接纯函数(可单测) | 新建 |
|
||
| `feature_capabilities/Index.ets` | 模块导出 | 改:导出 `buildShareText`(供单测) |
|
||
| `feature_capabilities/src/main/ets/wx/WeChatApi.ets` | 微信 SDK 封装 | 改:删分享路径,仅留登录授权 |
|
||
| `feature_capabilities/src/main/ets/providers/ShareProvider.ets` | 分享能力 | 重写(整文件替换) |
|
||
| `entry/src/main/ets/components/ShareGuideDialog.ets` | 指引弹窗 UI | 新建 |
|
||
| `entry/src/main/ets/pages/BridgeGameContainer.ets` | 容器接线 | 改:订阅/渲染 `ShareGuideDialog` |
|
||
| `entry/src/ohosTest/.../ShareText.test.ets` | buildShareText 单测 | 新建 |
|
||
|
||
---
|
||
|
||
## Task 1: common 新增指引窗事件与载荷
|
||
|
||
**Files:**
|
||
- Modify: `common/src/main/ets/event/ShareEvents.ets`
|
||
- Modify: `common/Index.ets`
|
||
|
||
- [ ] **Step 1: 在 `ShareEvents` 类内新增 `SHOW_GUIDE` 事件名**
|
||
|
||
编辑 `common/src/main/ets/event/ShareEvents.ets`,在 `PHOTO_UPLOAD` 常量后追加:
|
||
|
||
```typescript
|
||
/** 请求弹出分享指引窗(渠道选定后,由 ShareProvider 发,BridgeGameContainer 弹窗)。 */
|
||
static readonly SHOW_GUIDE: string = 'share.showGuide';
|
||
```
|
||
|
||
- [ ] **Step 2: 在文件末尾追加两个载荷 interface**
|
||
|
||
```typescript
|
||
/** SHOW_GUIDE 载荷。 */
|
||
export interface ShareGuideRequest {
|
||
/** 渠道:'wechat' | 'qq' | 'douyin'。 */
|
||
platform: string;
|
||
/** 内容类型:'text' | 'image'(决定指引文案与系统分享走 systemText/systemImage)。 */
|
||
contentKind: string;
|
||
/** 用户选定动作后回投的一次性事件名(ShareProvider 已 EventBus.once 监听)。 */
|
||
resultEvent: string;
|
||
}
|
||
|
||
/** 指引窗动作回投载荷。 */
|
||
export interface ShareGuideResult {
|
||
/** 'open'(打开App)| 'system'(用系统分享)| 'cancel'。 */
|
||
action: string;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 在 `common/Index.ets` 导出新 interface**
|
||
|
||
把第 9 行的导出改为(追加 `ShareGuideRequest, ShareGuideResult`):
|
||
|
||
```typescript
|
||
export { ShareEvents, SharePanelRequest, SharePanelResult, ShareGuideRequest, ShareGuideResult, PhotoUploadPayload }
|
||
from './src/main/ets/event/ShareEvents';
|
||
```
|
||
|
||
- [ ] **Step 4: 编译验证**
|
||
|
||
Run: `devecocli build`
|
||
Expected: `BUILD SUCCESSFUL`
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add common/src/main/ets/event/ShareEvents.ets common/Index.ets
|
||
git commit -m "feat(share): common 新增 SHOW_GUIDE 事件与 ShareGuide 载荷(分享重构 §4)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: module.json5 声明 QQ/抖音 querySchemes
|
||
|
||
**Files:**
|
||
- Modify: `entry/src/main/module.json5:13-15`
|
||
|
||
- [ ] **Step 1: 扩充 querySchemes**
|
||
|
||
把:
|
||
|
||
```json5
|
||
"querySchemes": [
|
||
"weixin"
|
||
],
|
||
```
|
||
|
||
改为:
|
||
|
||
```json5
|
||
"querySchemes": [
|
||
"weixin",
|
||
"mqqapi",
|
||
"snssdk1128"
|
||
],
|
||
```
|
||
|
||
- [ ] **Step 2: 编译验证**
|
||
|
||
Run: `devecocli build`
|
||
Expected: `BUILD SUCCESSFUL`
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add entry/src/main/module.json5
|
||
git commit -m "feat(share): querySchemes 增加 mqqapi/snssdk1128 供指引窗拉起QQ/抖音(分享重构 §7.4)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: 分享文本拼接纯函数(可单测)
|
||
|
||
**Files:**
|
||
- Create: `feature_capabilities/src/main/ets/share/ShareText.ets`
|
||
- Modify: `feature_capabilities/Index.ets`
|
||
|
||
- [ ] **Step 1: 新建 `ShareText.ets`**
|
||
|
||
```typescript
|
||
/**
|
||
* 分享文本拼接(纯函数,可单测)。
|
||
* 规则(对齐原工程 + 用户确认):`title\ndescription`,**单换行、三端一致、不含 url**;空字段跳过。
|
||
*/
|
||
export function buildShareText(title: string, description: string): string {
|
||
const parts: string[] = [];
|
||
if (title !== '') {
|
||
parts.push(title);
|
||
}
|
||
if (description !== '') {
|
||
parts.push(description);
|
||
}
|
||
return parts.join('\n');
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 在 `feature_capabilities/Index.ets` 末尾导出(供单测)**
|
||
|
||
```typescript
|
||
export { buildShareText } from './src/main/ets/share/ShareText';
|
||
```
|
||
|
||
- [ ] **Step 3: 编译验证**
|
||
|
||
Run: `devecocli build`
|
||
Expected: `BUILD SUCCESSFUL`
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add feature_capabilities/src/main/ets/share/ShareText.ets feature_capabilities/Index.ets
|
||
git commit -m "feat(share): 抽出 buildShareText 纯函数 title\\ndescription 单换行无url(分享重构 §5.1)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: WeChatApi 去除分享路径(仅留登录授权)
|
||
|
||
**Files:**
|
||
- Modify: `feature_capabilities/src/main/ets/wx/WeChatApi.ets`(整文件替换)
|
||
|
||
> 背景:去 SDK 仅针对**分享**;微信**登录**仍用 `@tencent/wechat_open_sdk`,依赖必须保留。本任务删掉 `sendShare`/`ShareRespCallback`/`SendMessageToWXResp` 分支。
|
||
|
||
- [ ] **Step 1: 整文件替换为**
|
||
|
||
```typescript
|
||
/**
|
||
* 微信开放平台 SDK 封装(@tencent/wechat_open_sdk)。单例。**仅用于登录授权**(分享已去 SDK 化)。
|
||
*
|
||
* 职责:持有 WXApi(用 AppID 创建)、发起授权请求、接收微信回调并路由到 authCb。
|
||
* AppID 为公开标识,可入客户端;AppSecret 绝不入端(登录换 profile 须服务端,§13 红线)。
|
||
*/
|
||
import { common, Want } from '@kit.AbilityKit';
|
||
import {
|
||
WXAPIFactory, WXApi, WXApiEventHandler, BaseReq, BaseResp, SendAuthResp,
|
||
} from '@tencent/wechat_open_sdk';
|
||
import { Logger } from 'common';
|
||
|
||
/** 微信 AppID(公开标识,契约 §13)。 */
|
||
export const WX_APP_ID: string = 'wxd2bd650e06bdfe58';
|
||
|
||
export type AuthRespCallback = (resp: SendAuthResp) => void;
|
||
|
||
export class WeChatApi {
|
||
private static readonly log: Logger = Logger.tag('WeChatApi');
|
||
private static inst: WeChatApi | undefined = undefined;
|
||
private readonly api: WXApi;
|
||
private readonly handler: WXApiEventHandler;
|
||
private authCb: AuthRespCallback | undefined = undefined;
|
||
|
||
private constructor() {
|
||
this.api = WXAPIFactory.createWXAPI(WX_APP_ID);
|
||
this.handler = {
|
||
onReq: (_req: BaseReq) => { },
|
||
onResp: (resp: BaseResp) => this.routeResp(resp),
|
||
};
|
||
}
|
||
|
||
static getInstance(): WeChatApi {
|
||
if (WeChatApi.inst === undefined) {
|
||
WeChatApi.inst = new WeChatApi();
|
||
}
|
||
return WeChatApi.inst;
|
||
}
|
||
|
||
isWXInstalled(): boolean {
|
||
try {
|
||
return this.api.isWXAppInstalled();
|
||
} catch (e) {
|
||
WeChatApi.log.w(`isWXAppInstalled failed: ${(e as Error).message}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** 发起授权登录;resp 经 cb 异步回调。 */
|
||
sendAuth(context: common.UIAbilityContext, req: BaseReq, cb: AuthRespCallback): void {
|
||
this.authCb = cb;
|
||
this.api.sendReq(context, req);
|
||
}
|
||
|
||
/** 由 EntryAbility 在 onNewWant/onCreate 调用,处理微信回调 want。 */
|
||
handleWant(want: Want): void {
|
||
try {
|
||
this.api.handleWant(want, this.handler);
|
||
} catch (e) {
|
||
WeChatApi.log.w(`handleWant failed: ${(e as Error).message}`);
|
||
}
|
||
}
|
||
|
||
private routeResp(resp: BaseResp): void {
|
||
if (resp instanceof SendAuthResp) {
|
||
const cb = this.authCb;
|
||
this.authCb = undefined;
|
||
if (cb !== undefined) {
|
||
cb(resp);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 编译验证**
|
||
|
||
Run: `devecocli build`
|
||
Expected: `BUILD FAILED`(因 `ShareProvider.ets` 仍 import `WeChatApi.sendShare` 等——预期,下一任务修复)
|
||
|
||
> 若想保持每步 build 绿,可与 Task 5 合并提交;这里单独列出便于审查。执行 subagent 可先做 Task 5 的整文件替换再一起 build。
|
||
|
||
- [ ] **Step 3:(暂不单独提交,与 Task 5 一起验证后提交)**
|
||
|
||
跳到 Task 5。
|
||
|
||
---
|
||
|
||
## Task 5: 重写 ShareProvider(核心)
|
||
|
||
**Files:**
|
||
- Modify: `feature_capabilities/src/main/ets/providers/ShareProvider.ets`(整文件替换)
|
||
|
||
- [ ] **Step 1: 整文件替换为**
|
||
|
||
```typescript
|
||
/**
|
||
* 分享能力(契约 §8.1/§10.1)。三端统一「剪贴板+指引窗 → 系统分享」,**不依赖任何分享 SDK**。
|
||
*
|
||
* H5 调 friendsSharetypeUrlToptitleDescript(data=sharetypeBean JSON):
|
||
* - sharefriend=="2" → 直接微信指引窗(朋友圈语义,回传 type=2)。
|
||
* - sharefriend=="1" → 弹自定义面板(微信/QQ/抖音/取消) → 选定渠道。
|
||
* 选定渠道后按内容类型组装并写剪贴板,再弹 ShareGuideDialog:
|
||
* - 文本(type1/4/其他):剪贴板 PLAIN_TEXT(title\ndescription,无 url);
|
||
* - 图片(type2 截图 / type3 图片链接下载,低优先):剪贴板 PIXELMAP(尽力)。
|
||
* 指引窗动作:打开App(openLink scheme,best-effort) / 用系统分享(systemText|systemImage,可靠) / 取消。
|
||
* 仅微信乐观回传 sharesuccess:打开/系统分享→{success:2,type};取消→{success:3,type}。QQ/抖音不回传。
|
||
*
|
||
* 注:图片"复制剪贴板再去对方App粘贴"能否成功取决于对方是否读 PIXELMAP(鸿蒙不可保证),
|
||
* 故指引窗"用系统分享"(systemImage) 为可靠兜底;拉起 App 仅打开、不预填(需用户自行粘贴)。
|
||
*/
|
||
import { systemShare } from '@kit.ShareKit';
|
||
import { uniformTypeDescriptor as utd } from '@kit.ArkData';
|
||
import { fileUri } from '@kit.CoreFileKit';
|
||
import { common, bundleManager } from '@kit.AbilityKit';
|
||
import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
|
||
import { image } from '@kit.ImageKit';
|
||
import { util } from '@kit.ArkTS';
|
||
import { BridgeController } from 'feature_bridge';
|
||
import { InboundHandlers, OutboundHandlers, SharetypeBean, ShareSuccessResp } from 'contracts';
|
||
import {
|
||
EventBus, Logger, ShareEvents,
|
||
SharePanelRequest, SharePanelResult, ShareGuideRequest, ShareGuideResult, PhotoUploadPayload,
|
||
} from 'common';
|
||
import { FileSystem, Downloader } from 'platform';
|
||
import { CapabilityContext, CapabilityProvider } from '../core/CapabilityProvider';
|
||
import { buildShareText } from '../share/ShareText';
|
||
|
||
export class ShareProvider implements CapabilityProvider {
|
||
/** 进程内实例计数:双 Web 槽各一个实例,用它给 resultEvent 命名空间隔离。 */
|
||
private static instanceCount: number = 0;
|
||
readonly name: string = 'share';
|
||
private readonly log: Logger = Logger.tag('ShareProvider');
|
||
private readonly instanceId: number;
|
||
private bridge: BridgeController | undefined = undefined;
|
||
private ctx: CapabilityContext | undefined = undefined;
|
||
/** 落盘 + resultEvent 命名计数器(实例自增,避免 Math.random 串扰)。 */
|
||
private seq: number = 0;
|
||
private photoUploadCancel: (() => void) | undefined = undefined;
|
||
|
||
constructor() {
|
||
ShareProvider.instanceCount += 1;
|
||
this.instanceId = ShareProvider.instanceCount;
|
||
}
|
||
|
||
register(bridge: BridgeController, ctx: CapabilityContext): void {
|
||
this.bridge = bridge;
|
||
this.ctx = ctx;
|
||
bridge.registerHandler(InboundHandlers.FriendsShare, (data: string, _cb: (resp: string) => void) => {
|
||
this.onShare(data);
|
||
});
|
||
// 第二条链路:H5 主动 POST 截图到 LocalUploadServer → emit PHOTO_UPLOAD → 微信图片指引窗。
|
||
this.photoUploadCancel = EventBus.on(ShareEvents.PHOTO_UPLOAD, (p) => this.onPhotoUpload(p));
|
||
}
|
||
|
||
onDestroy(): void {
|
||
if (this.photoUploadCancel !== undefined) {
|
||
this.photoUploadCancel();
|
||
this.photoUploadCancel = undefined;
|
||
}
|
||
}
|
||
|
||
// —— 入站 ——
|
||
|
||
private onShare(data: string): void {
|
||
if (this.ctx === undefined) {
|
||
return;
|
||
}
|
||
let bean: SharetypeBean;
|
||
try {
|
||
bean = JSON.parse(data) as SharetypeBean;
|
||
} catch (e) {
|
||
this.log.w(`share bad json: ${(e as Error).message}`);
|
||
return;
|
||
}
|
||
if (bean === undefined || bean === null) {
|
||
this.log.w('share bean empty');
|
||
return;
|
||
}
|
||
if (bean.sharefriend === '2') {
|
||
// 朋友圈语义:不弹面板,直接微信指引窗(契约 §10.1)。
|
||
this.startShare(bean, 'wechat');
|
||
return;
|
||
}
|
||
this.seq += 1;
|
||
const resultEvent: string = `share.result.${this.instanceId}.${this.seq}`;
|
||
EventBus.once(resultEvent, (p) => this.onPlatformChosen(bean, p));
|
||
const req: SharePanelRequest = { data, resultEvent };
|
||
EventBus.emit(ShareEvents.SHOW_PANEL, req);
|
||
}
|
||
|
||
private onPlatformChosen(bean: SharetypeBean, payload?: Object): void {
|
||
const platform: string = payload !== undefined ? (payload as SharePanelResult).platform : 'cancel';
|
||
if (platform === 'wechat' || platform === 'qq' || platform === 'douyin') {
|
||
this.startShare(bean, platform);
|
||
}
|
||
// cancel:不分享、不回传。
|
||
}
|
||
|
||
/** H5 POST 截图上传:仅激活槽处理,落盘后走微信图片指引窗。 */
|
||
private onPhotoUpload(payload?: Object): void {
|
||
const ctx = this.ctx;
|
||
const bridge = this.bridge;
|
||
if (ctx === undefined || bridge === undefined || !bridge.isActive() || payload === undefined) {
|
||
return;
|
||
}
|
||
const up = payload as PhotoUploadPayload;
|
||
if (up.imageBase64 === '') {
|
||
this.log.w('photo upload empty image');
|
||
return;
|
||
}
|
||
const bean: SharetypeBean = {
|
||
sharefriend: up.type, type: '2', sharetype: '', webpageUrl: '', title: '', description: '',
|
||
};
|
||
const filePath: string = this.saveDataUrlToFile(ctx.uiAbilityContext, up.imageBase64);
|
||
if (filePath === '') {
|
||
this.log.w('photo upload save failed; report cancel');
|
||
this.reportResult(3, up.type === '1' ? 1 : 2);
|
||
return;
|
||
}
|
||
this.onImageReady(bean, 'wechat', filePath);
|
||
}
|
||
|
||
// —— 组装 + 弹指引窗 ——
|
||
|
||
private startShare(bean: SharetypeBean, platform: string): void {
|
||
if (bean.type === '2') {
|
||
this.startCanvasImageShare(bean, platform);
|
||
} else if (bean.type === '3') {
|
||
this.startLinkImageShare(bean, platform);
|
||
} else {
|
||
this.startTextShare(bean, platform);
|
||
}
|
||
}
|
||
|
||
private startTextShare(bean: SharetypeBean, platform: string): void {
|
||
this.writeClipboardText(buildShareText(bean.title, bean.description));
|
||
this.guide(bean, platform, 'text', '');
|
||
}
|
||
|
||
/** type2:截当前 Web canvas → 落盘 → 图片指引窗(捕获失败降级文本指引,非"图片转文本"语义)。 */
|
||
private startCanvasImageShare(bean: SharetypeBean, platform: string): void {
|
||
const ctx = this.ctx;
|
||
if (ctx === undefined) {
|
||
return;
|
||
}
|
||
const cap = ctx.captureCanvas;
|
||
if (cap === undefined) {
|
||
this.startTextShare(bean, platform);
|
||
return;
|
||
}
|
||
cap(bean.sharetype).then((dataUrl: string) => {
|
||
const filePath: string = dataUrl !== '' ? this.saveDataUrlToFile(ctx.uiAbilityContext, dataUrl) : '';
|
||
if (filePath !== '') {
|
||
this.onImageReady(bean, platform, filePath);
|
||
} else {
|
||
this.log.w('canvas capture empty; fallback to text guide');
|
||
this.startTextShare(bean, platform);
|
||
}
|
||
}).catch((e: Error) => {
|
||
this.log.w(`captureCanvas failed: ${e.message}; fallback to text guide`);
|
||
this.startTextShare(bean, platform);
|
||
});
|
||
}
|
||
|
||
/** type3(低优先,需确认 H5 是否使用):从 webpageUrl 下载图片 → 落盘 → 图片指引窗(失败降级文本)。 */
|
||
private startLinkImageShare(bean: SharetypeBean, platform: string): void {
|
||
const ctx = this.ctx;
|
||
if (ctx === undefined) {
|
||
return;
|
||
}
|
||
const url: string = bean.webpageUrl;
|
||
if (url === '' || !(url.startsWith('http://') || url.startsWith('https://'))) {
|
||
this.startTextShare(bean, platform);
|
||
return;
|
||
}
|
||
this.seq += 1;
|
||
const ext: string = url.toLowerCase().indexOf('.png') >= 0 ? '.png' : '.jpg';
|
||
const savePath: string = `${ctx.uiAbilityContext.filesDir}/share/link_${this.seq}${ext}`;
|
||
Downloader.download(url, savePath).then(() => {
|
||
this.onImageReady(bean, platform, savePath);
|
||
}).catch((e: Error) => {
|
||
this.log.w(`link image download failed: ${e.message}; fallback to text guide`);
|
||
this.startTextShare(bean, platform);
|
||
});
|
||
}
|
||
|
||
private onImageReady(bean: SharetypeBean, platform: string, filePath: string): void {
|
||
this.writeClipboardImage(filePath);
|
||
this.guide(bean, platform, 'image', filePath);
|
||
}
|
||
|
||
private guide(bean: SharetypeBean, platform: string, kind: string, filePath: string): void {
|
||
this.seq += 1;
|
||
const resultEvent: string = `share.guide.${this.instanceId}.${this.seq}`;
|
||
EventBus.once(resultEvent, (p) => this.onGuideAction(bean, platform, kind, filePath, p));
|
||
const req: ShareGuideRequest = { platform, contentKind: kind, resultEvent };
|
||
EventBus.emit(ShareEvents.SHOW_GUIDE, req);
|
||
}
|
||
|
||
private onGuideAction(bean: SharetypeBean, platform: string, kind: string, filePath: string, payload?: Object): void {
|
||
const ctx = this.ctx;
|
||
if (ctx === undefined) {
|
||
return;
|
||
}
|
||
const uiCtx: common.UIAbilityContext = ctx.uiAbilityContext;
|
||
const action: string = payload !== undefined ? (payload as ShareGuideResult).action : 'cancel';
|
||
const reportType: number = bean.sharefriend === '1' ? 1 : 2;
|
||
if (action === 'open') {
|
||
this.openApp(uiCtx, platform);
|
||
if (platform === 'wechat') {
|
||
this.reportResult(2, reportType);
|
||
}
|
||
} else if (action === 'system') {
|
||
if (kind === 'image' && filePath !== '') {
|
||
this.systemImage(uiCtx, filePath, bean);
|
||
} else {
|
||
this.systemText(uiCtx, bean);
|
||
}
|
||
if (platform === 'wechat') {
|
||
this.reportResult(2, reportType);
|
||
}
|
||
} else {
|
||
// cancel
|
||
if (platform === 'wechat') {
|
||
this.reportResult(3, reportType);
|
||
}
|
||
}
|
||
}
|
||
|
||
// —— 拉起 App(best-effort,仅打开不预填)——
|
||
|
||
private schemeOf(platform: string): string {
|
||
if (platform === 'qq') {
|
||
return 'mqqapi://';
|
||
}
|
||
if (platform === 'douyin') {
|
||
return 'snssdk1128://';
|
||
}
|
||
return 'weixin://';
|
||
}
|
||
|
||
private openApp(uiCtx: common.UIAbilityContext, platform: string): void {
|
||
const link: string = this.schemeOf(platform);
|
||
let canOpen: boolean = false;
|
||
try {
|
||
canOpen = bundleManager.canOpenLink(link);
|
||
} catch (e) {
|
||
this.log.w(`canOpenLink failed: ${(e as BusinessError).message}`);
|
||
return;
|
||
}
|
||
if (!canOpen) {
|
||
this.log.i(`app not openable: ${link}`);
|
||
return;
|
||
}
|
||
uiCtx.openLink(link, { appLinkingOnly: false }).catch((e: BusinessError) => {
|
||
this.log.w(`openLink failed: code=${e.code} ${e.message}`);
|
||
});
|
||
}
|
||
|
||
// —— 剪贴板 ——
|
||
|
||
private writeClipboardText(text: string): void {
|
||
try {
|
||
const data: pasteboard.PasteData =
|
||
pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text !== '' ? text : ' ');
|
||
pasteboard.getSystemPasteboard().setData(data)
|
||
.catch((e: BusinessError) => this.log.w(`clipboard text failed: ${e.code} ${e.message}`));
|
||
} catch (e) {
|
||
this.log.w(`clipboard text init failed: ${(e as Error).message}`);
|
||
}
|
||
}
|
||
|
||
/** 图片写剪贴板(尽力;对方能否粘贴 PIXELMAP 不可保证,systemImage 才是可靠交付)。 */
|
||
private writeClipboardImage(filePath: string): void {
|
||
const source: image.ImageSource = image.createImageSource(filePath);
|
||
source.createPixelMap().then((pm: image.PixelMap) => {
|
||
try {
|
||
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_PIXELMAP, pm);
|
||
pasteboard.getSystemPasteboard().setData(data)
|
||
.catch((e: BusinessError) => this.log.w(`clipboard image failed: ${e.code} ${e.message}`));
|
||
} catch (e) {
|
||
this.log.w(`clipboard image init failed: ${(e as Error).message}`);
|
||
}
|
||
}).catch((e: BusinessError) => {
|
||
this.log.w(`createPixelMap failed: ${e.code} ${e.message}`);
|
||
});
|
||
}
|
||
|
||
// —— 系统分享(可靠兜底;文本不下发 url)——
|
||
|
||
private systemText(uiCtx: common.UIAbilityContext, bean: SharetypeBean): void {
|
||
const text: string = buildShareText(bean.title, bean.description);
|
||
const record: systemShare.SharedRecord = {
|
||
utd: utd.UniformDataType.PLAIN_TEXT, content: text !== '' ? text : ' ',
|
||
};
|
||
this.showSystemShare(uiCtx, record);
|
||
}
|
||
|
||
private systemImage(uiCtx: common.UIAbilityContext, filePath: string, bean: SharetypeBean): void {
|
||
const typeId: string =
|
||
utd.getUniformDataTypeByFilenameExtension(this.extOf(filePath), utd.UniformDataType.IMAGE);
|
||
const record: systemShare.SharedRecord = {
|
||
utd: typeId,
|
||
uri: fileUri.getUriFromPath(filePath),
|
||
title: bean.title !== '' ? bean.title : undefined,
|
||
description: bean.description !== '' ? bean.description : undefined,
|
||
};
|
||
this.showSystemShare(uiCtx, record);
|
||
}
|
||
|
||
private showSystemShare(uiCtx: common.UIAbilityContext, record: systemShare.SharedRecord): void {
|
||
try {
|
||
const data: systemShare.SharedData = new systemShare.SharedData(record);
|
||
const controller: systemShare.ShareController = new systemShare.ShareController(data);
|
||
controller.show(uiCtx, {
|
||
selectionMode: systemShare.SelectionMode.SINGLE,
|
||
previewMode: systemShare.SharePreviewMode.DETAIL,
|
||
}).catch((e: BusinessError) => {
|
||
this.log.w(`systemShare show failed: code=${e.code} ${e.message}`);
|
||
});
|
||
} catch (e) {
|
||
this.log.w(`systemShare init failed: ${(e as BusinessError).message}`);
|
||
}
|
||
}
|
||
|
||
// —— 公共 ——
|
||
|
||
private reportResult(success: number, type: number): void {
|
||
const out: ShareSuccessResp = { success, type };
|
||
this.bridge?.callHandler(OutboundHandlers.ShareSuccess, JSON.stringify(out));
|
||
}
|
||
|
||
/** dataURL/纯base64 → 解码 → 写沙箱 filesDir/share/shot_<seq>.jpg|png → 返回路径;失败返回空串。 */
|
||
private saveDataUrlToFile(uiCtx: common.UIAbilityContext, dataUrl: string): string {
|
||
try {
|
||
const comma: number = dataUrl.indexOf(',');
|
||
const base64: string = comma >= 0 ? dataUrl.substring(comma + 1) : dataUrl;
|
||
if (base64 === '') {
|
||
return '';
|
||
}
|
||
const bytes: Uint8Array = new util.Base64Helper().decodeSync(base64);
|
||
this.seq += 1;
|
||
const ext: string = dataUrl.indexOf('image/png') >= 0 ? '.png' : '.jpg';
|
||
const filePath: string = `${uiCtx.filesDir}/share/shot_${this.seq}${ext}`;
|
||
FileSystem.writeBytes(filePath, bytes);
|
||
return filePath;
|
||
} catch (e) {
|
||
this.log.w(`saveDataUrlToFile failed: ${(e as Error).message}`);
|
||
return '';
|
||
}
|
||
}
|
||
|
||
private extOf(path: string): string {
|
||
const dot: number = path.lastIndexOf('.');
|
||
const slash: number = path.lastIndexOf('/');
|
||
return dot > slash && dot >= 0 ? path.substring(dot) : '.jpg';
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 编译验证(含 Task 4)**
|
||
|
||
Run: `devecocli build`
|
||
Expected: `BUILD SUCCESSFUL`
|
||
|
||
- [ ] **Step 3: 提交(Task 4 + Task 5 一起)**
|
||
|
||
```bash
|
||
git add feature_capabilities/src/main/ets/wx/WeChatApi.ets feature_capabilities/src/main/ets/providers/ShareProvider.ets
|
||
git commit -m "feat(share): ShareProvider 去SDK化三段式分享 + WeChatApi 仅留登录(分享重构 §4/§5/§7)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: 新增 ShareGuideDialog 指引弹窗
|
||
|
||
**Files:**
|
||
- Create: `entry/src/main/ets/components/ShareGuideDialog.ets`
|
||
|
||
- [ ] **Step 1: 新建组件**
|
||
|
||
```typescript
|
||
/**
|
||
* 分享指引弹窗(渠道选定后弹出)。纯展示组件:动作经 onPick 回容器,
|
||
* 由容器一次性 resultEvent 回投 ShareProvider(同 SharePanel 模式,不持业务逻辑)。
|
||
* 文案按 contentKind:文本→"请打开XX粘贴分享";图片→"可在XX长按粘贴;或用系统分享"。
|
||
*/
|
||
@Component
|
||
export struct ShareGuideDialog {
|
||
/** 渠道:'wechat' | 'qq' | 'douyin'。 */
|
||
platform: string = 'wechat';
|
||
/** 内容类型:'text' | 'image'。 */
|
||
contentKind: string = 'text';
|
||
/** 动作回调:'open' | 'system' | 'cancel'。 */
|
||
onPick: (action: string) => void = () => { };
|
||
|
||
private appName(): string {
|
||
if (this.platform === 'qq') {
|
||
return 'QQ';
|
||
}
|
||
if (this.platform === 'douyin') {
|
||
return '抖音';
|
||
}
|
||
return '微信';
|
||
}
|
||
|
||
private appIcon(): Resource {
|
||
if (this.platform === 'qq') {
|
||
return $r('app.media.share_qq');
|
||
}
|
||
if (this.platform === 'douyin') {
|
||
return $r('app.media.share_douyin');
|
||
}
|
||
return $r('app.media.share_wechat');
|
||
}
|
||
|
||
private hint(): string {
|
||
const name: string = this.appName();
|
||
return this.contentKind === 'image'
|
||
? `图片已复制,可在${name}长按粘贴;或点下方用系统分享`
|
||
: `内容已复制,请打开${name}粘贴分享`;
|
||
}
|
||
|
||
build() {
|
||
Column() {
|
||
Blank()
|
||
.layoutWeight(1)
|
||
.width('100%')
|
||
.onClick(() => this.onPick('cancel'))
|
||
|
||
Column() {
|
||
Image(this.appIcon())
|
||
.width(48)
|
||
.height(48)
|
||
.objectFit(ImageFit.Contain)
|
||
.margin({ top: 16 })
|
||
Text(this.hint())
|
||
.fontSize(14)
|
||
.fontColor('#333333')
|
||
.textAlign(TextAlign.Center)
|
||
.margin({ top: 12, bottom: 12 })
|
||
.padding({ left: 20, right: 20 })
|
||
|
||
Divider().color('#EEEEEE')
|
||
Text(`打开${this.appName()}`)
|
||
.fontSize(16)
|
||
.fontColor('#1989FA')
|
||
.width('100%')
|
||
.textAlign(TextAlign.Center)
|
||
.padding({ top: 14, bottom: 14 })
|
||
.onClick(() => this.onPick('open'))
|
||
Divider().color('#EEEEEE')
|
||
Text('用系统分享')
|
||
.fontSize(16)
|
||
.fontColor('#333333')
|
||
.width('100%')
|
||
.textAlign(TextAlign.Center)
|
||
.padding({ top: 14, bottom: 14 })
|
||
.onClick(() => this.onPick('system'))
|
||
Divider().color('#EEEEEE')
|
||
Text('取消')
|
||
.fontSize(16)
|
||
.fontColor('#666666')
|
||
.width('100%')
|
||
.textAlign(TextAlign.Center)
|
||
.padding({ top: 14, bottom: 14 })
|
||
.onClick(() => this.onPick('cancel'))
|
||
}
|
||
.width('100%')
|
||
.backgroundColor(Color.White)
|
||
.borderRadius({ topLeft: 16, topRight: 16 })
|
||
.onClick(() => { /* 消费点击,避免穿透到遮罩 */ })
|
||
}
|
||
.width('100%')
|
||
.height('100%')
|
||
.backgroundColor('rgba(0,0,0,0.45)')
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 编译验证**
|
||
|
||
Run: `devecocli build`
|
||
Expected: `BUILD SUCCESSFUL`(组件未被引用也应通过编译)
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add entry/src/main/ets/components/ShareGuideDialog.ets
|
||
git commit -m "feat(share): 新增 ShareGuideDialog 指引弹窗(打开App/用系统分享/取消,分享重构 §7.2)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: BridgeGameContainer 接线指引弹窗
|
||
|
||
**Files:**
|
||
- Modify: `entry/src/main/ets/pages/BridgeGameContainer.ets`(4 处:import / 状态 / 订阅+方法 / 渲染+返回键)
|
||
|
||
- [ ] **Step 1: 扩充 import**
|
||
|
||
把第 8-9 行的 common import 追加 `ShareGuideRequest, ShareGuideResult`:
|
||
|
||
```typescript
|
||
import { AppEnv, EventBus, EventPayload, NavEvents, OpenGenericWebPayload, SwitchGamePayload,
|
||
BackGamePayload, ShareEvents, SharePanelRequest, SharePanelResult, ShareGuideRequest, ShareGuideResult, Logger } from 'common';
|
||
```
|
||
|
||
在第 12 行 `import { SharePanel }` 后新增:
|
||
|
||
```typescript
|
||
import { ShareGuideDialog } from '../components/ShareGuideDialog';
|
||
```
|
||
|
||
- [ ] **Step 2: 新增指引窗状态(紧接现有 `shareResultEvent` 定义之后,约第 54 行后)**
|
||
|
||
```typescript
|
||
/** 分享指引窗显隐(渠道选定后叠在 Stack 顶层)。 */
|
||
@State private shareGuideVisible: boolean = false;
|
||
private shareGuidePlatform: string = '';
|
||
private shareGuideKind: string = '';
|
||
/** 当前指引窗的一次性回投事件名(用户选动作后 emit 回 ShareProvider)。 */
|
||
private shareGuideResultEvent: string = '';
|
||
```
|
||
|
||
- [ ] **Step 3: 订阅 SHOW_GUIDE(在 `subscribeEvents()` 内 SHOW_PANEL 订阅那一行之后,约第 122 行)**
|
||
|
||
```typescript
|
||
this.cancels.push(EventBus.on(ShareEvents.SHOW_GUIDE, (p?: EventPayload) => this.showShareGuide(p)));
|
||
```
|
||
|
||
- [ ] **Step 4: 新增两个方法(紧接现有 `emitShareResult()` 方法之后,约第 147 行)**
|
||
|
||
```typescript
|
||
/** 收到 ShareProvider 的 SHOW_GUIDE:记下渠道/类型/一次性回投事件名并弹出指引窗。 */
|
||
private showShareGuide(p?: EventPayload): void {
|
||
if (p === undefined) {
|
||
return;
|
||
}
|
||
// 已有指引窗未关闭:先按取消回投旧请求,避免上一个 once 永不触发致 ShareProvider 悬挂。
|
||
if (this.shareGuideVisible && this.shareGuideResultEvent !== '') {
|
||
this.emitShareGuideResult('cancel');
|
||
}
|
||
const req = p as ShareGuideRequest;
|
||
this.shareGuidePlatform = req.platform;
|
||
this.shareGuideKind = req.contentKind;
|
||
this.shareGuideResultEvent = req.resultEvent;
|
||
this.shareGuideVisible = true;
|
||
}
|
||
|
||
/** 用户选定动作/取消:回投并关闭指引窗。 */
|
||
private emitShareGuideResult(action: string): void {
|
||
const ev: string = this.shareGuideResultEvent;
|
||
this.shareGuideResultEvent = '';
|
||
this.shareGuideVisible = false;
|
||
if (ev !== '') {
|
||
const result: ShareGuideResult = { action };
|
||
EventBus.emit(ev, result);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 渲染指引窗(在渲染 SharePanel 的 `if (this.sharePanelVisible) {...}` 块之后,约第 392 行后)**
|
||
|
||
```typescript
|
||
// 分享指引窗(顶层叠加;渠道选定后弹,选动作/取消 → 一次性回投 ShareProvider)
|
||
if (this.shareGuideVisible) {
|
||
ShareGuideDialog({
|
||
platform: this.shareGuidePlatform,
|
||
contentKind: this.shareGuideKind,
|
||
onPick: (action: string) => this.emitShareGuideResult(action),
|
||
})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: 返回键关闭指引窗(在 `onBackPressed` 内 `if (this.sharePanelVisible) {...}` 块之前,约第 401 行前)**
|
||
|
||
```typescript
|
||
// 指引窗打开时,返回键关闭并按取消回投
|
||
if (this.shareGuideVisible) {
|
||
this.emitShareGuideResult('cancel');
|
||
return true;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: 编译验证**
|
||
|
||
Run: `devecocli build`
|
||
Expected: `BUILD SUCCESSFUL`
|
||
|
||
- [ ] **Step 8: 提交**
|
||
|
||
```bash
|
||
git add entry/src/main/ets/pages/BridgeGameContainer.ets
|
||
git commit -m "feat(share): 容器接线 ShareGuideDialog(订阅 SHOW_GUIDE/渲染/返回键/一次性回投,分享重构 §7)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: buildShareText 单元测试
|
||
|
||
**Files:**
|
||
- Create: `entry/src/ohosTest/ets/test/ShareText.test.ets`
|
||
- Modify: `entry/src/ohosTest/ets/test/List.test.ets`(注册新测试套,若该聚合文件存在)
|
||
|
||
> 说明:`devecocli` 无 test 子命令,此测试在 DevEco Studio 内运行(Run > Test)。CLI 阶段以 `devecocli build` 编译通过为门禁。
|
||
|
||
- [ ] **Step 1: 新建测试文件**
|
||
|
||
```typescript
|
||
import { describe, it, expect } from '@ohos/hypium';
|
||
import { buildShareText } from 'feature_capabilities';
|
||
|
||
export default function shareTextTest() {
|
||
describe('buildShareText', () => {
|
||
it('title+description 单换行', 0, () => {
|
||
expect(buildShareText('标题', '描述')).assertEqual('标题\n描述');
|
||
});
|
||
it('仅 title', 0, () => {
|
||
expect(buildShareText('标题', '')).assertEqual('标题');
|
||
});
|
||
it('仅 description', 0, () => {
|
||
expect(buildShareText('', '描述')).assertEqual('描述');
|
||
});
|
||
it('都为空返回空串', 0, () => {
|
||
expect(buildShareText('', '')).assertEqual('');
|
||
});
|
||
it('不含 url(webpageUrl 不参与拼接)', 0, () => {
|
||
expect(buildShareText('标题', '描述')).assertEqual('标题\n描述');
|
||
});
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 注册测试套(若 `entry/src/ohosTest/ets/test/List.test.ets` 存在)**
|
||
|
||
在 `List.test.ets` 的聚合函数体内追加调用(与现有 `xxxTest()` 同级):
|
||
|
||
```typescript
|
||
import shareTextTest from './ShareText.test';
|
||
// ...在 testsuite() 函数体内:
|
||
shareTextTest();
|
||
```
|
||
|
||
> 若实际测试目录结构与上述路径不同,按本仓库现有 `*.test.ets` 的位置与注册方式对齐放置(先 `ls entry/src/ohosTest/ets/test/` 与 `entry/src/test/` 确认)。
|
||
|
||
- [ ] **Step 3: 编译验证**
|
||
|
||
Run: `devecocli build`
|
||
Expected: `BUILD SUCCESSFUL`
|
||
|
||
- [ ] **Step 4: 提交**
|
||
|
||
```bash
|
||
git add entry/src/ohosTest/ets/test/ShareText.test.ets entry/src/ohosTest/ets/test/List.test.ets
|
||
git commit -m "test(share): buildShareText 单测(单换行/空字段/无url,分享重构 §9)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: 真机验证 + 进度/风险登记
|
||
|
||
**Files:**
|
||
- Modify: `docs/设计文档/Plan/01_任务分解WBS.md`(T-M3-13 状态/备注)
|
||
- Modify: `docs/设计文档/Plan/03_风险登记册.md`(新增图片剪贴板粘贴风险)
|
||
|
||
- [ ] **Step 1: 全量构建**
|
||
|
||
Run: `devecocli build`
|
||
Expected: `BUILD SUCCESSFUL`
|
||
|
||
- [ ] **Step 2: 真机/模拟器行为验证(需设备)**
|
||
|
||
Run: `devecocli run`,在 H5 触发分享,逐项核对:
|
||
- `sharefriend=="1"`:弹三按钮面板 → 选微信/QQ/抖音 → 弹指引窗。
|
||
- `sharefriend=="2"`:不弹面板,直接微信指引窗。
|
||
- 文本(type1):剪贴板为 `title\n描述`(无 url);「用系统分享」拉起系统面板 PLAIN_TEXT。
|
||
- 图片(type2):截图落盘;「用系统分享」拉起系统面板带图片。
|
||
- 「打开微信/QQ/抖音」:`canOpenLink` 通过则打开对应 App(best-effort)。
|
||
- 微信渠道:点「打开」/「用系统分享」回传 `sharesuccess {success:2,type}`;取消回 `{success:3}`;QQ/抖音无回传。
|
||
- **人工**:图片 PIXELMAP 复制后,在微信/QQ 鸿蒙版对话框长按是否能粘贴出图片 → 结果登记风险册。
|
||
|
||
- [ ] **Step 3: 更新 WBS(T-M3-13)**
|
||
|
||
先读 `docs/设计文档/Plan/01_任务分解WBS.md` 找到 T-M3-13(分享),把其状态/备注更新为:分享去 SDK 化重构完成(剪贴板+指引+系统分享),微信乐观回传;QQ/抖音 SDK 直分享列为延期项。
|
||
|
||
- [ ] **Step 4: 登记风险**
|
||
|
||
先读 `docs/设计文档/Plan/03_风险登记册.md`,按其既有行格式追加一行风险:
|
||
- 风险:图片复制到剪贴板后,微信/QQ 鸿蒙版对话框能否粘贴出 PIXELMAP 不可保证(API 12+ 读剪贴板有授权管控)。
|
||
- 应对:图片以指引窗内 `systemImage`(系统分享)为可靠交付,剪贴板图片仅尽力而为;待人工真机验证结果决定是否保留剪贴板图片路径。
|
||
- 关联:分享重构 spec §10 / T-M3-13。
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add docs/设计文档/Plan/01_任务分解WBS.md docs/设计文档/Plan/03_风险登记册.md
|
||
git commit -m "docs(share): 分享重构完成,更新 WBS(T-M3-13) + 登记图片剪贴板粘贴真机验证风险"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review(已对照 spec)
|
||
|
||
**Spec 覆盖:**
|
||
- §3 契约零改动 → 全程未改 `contracts`,handler/DTO 不变 ✓
|
||
- §4 流程(sharefriend 分流、指引窗三按钮) → Task 5 `onShare`/`guide` + Task 6/7 ✓
|
||
- §5.1 文本统一单换行无 url → Task 3 `buildShareText` + Task 8 单测 ✓
|
||
- §5.2 type1 文本 / type2 截图图片 / type3 下载图片 / type4 不实现 → Task 5 `startShare` 分支 ✓
|
||
- §6 微信乐观回传 → Task 5 `onGuideAction` ✓
|
||
- §7.1 ShareProvider 重写 + systemLink/systemVideo 退役 → Task 5(新文件无此二方法)✓
|
||
- §7.2 ShareGuideDialog → Task 6 ✓
|
||
- §7.3 WeChatApi 仅留登录 → Task 4 ✓
|
||
- §7.4 querySchemes → Task 2 ✓
|
||
- §7.5 保留 wechat_open_sdk → 未删依赖 ✓
|
||
- §7.6 支付现状已满足 → 不涉及,无任务(符合)✓
|
||
- §9 测试 → Task 8 + Task 9 真机项 ✓
|
||
- §10 风险登记 → Task 9 ✓
|
||
|
||
**占位扫描:** 无 TODO/TBD;所有代码步骤含完整代码。type3 标注"低优先/需确认 H5 使用"但有完整实现(非占位)。
|
||
|
||
**类型一致性:** `buildShareText(title, description)` 跨 Task 3/5/8 一致;`ShareGuideRequest{platform,contentKind,resultEvent}` / `ShareGuideResult{action}` 跨 Task 1/5/7 一致;动作字符串 `'open'|'system'|'cancel'`、渠道 `'wechat'|'qq'|'douyin'`、类型 `'text'|'image'` 全程一致。
|