# 分享重构(去 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_.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'` 全程一致。