Files
youle_app_ohos/docs/superpowers/plans/2026-06-27-分享重构.md
T

1010 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 分享重构(去 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 schemebest-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);
}
}
}
// —— 拉起 Appbest-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('不含 urlwebpageUrl 不参与拼接)', 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` 通过则打开对应 Appbest-effort)。
- 微信渠道:点「打开」/「用系统分享」回传 `sharesuccess {success:2,type}`;取消回 `{success:3}`QQ/抖音无回传。
- **人工**:图片 PIXELMAP 复制后,在微信/QQ 鸿蒙版对话框长按是否能粘贴出图片 → 结果登记风险册。
- [ ] **Step 3: 更新 WBST-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'` 全程一致。