From ffd0c1a338272c59030b745d1e85e368c97d18a5 Mon Sep 17 00:00:00 2001 From: lanterngamescn Date: Sat, 27 Jun 2026 13:14:57 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=88=86=E4=BA=AB=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92=EF=BC=889=20=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=EF=BC=8C=E5=8E=BBSDK=E5=8C=96+=E5=89=AA=E8=B4=B4?= =?UTF-8?q?=E6=9D=BF=E6=8C=87=E5=BC=95+=E7=B3=BB=E7=BB=9F=E5=88=86?= =?UTF-8?q?=E4=BA=AB=E5=85=9C=E5=BA=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-06-27-分享重构.md | 1009 +++++++++++++++++ 1 file changed, 1009 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-27-分享重构.md diff --git a/docs/superpowers/plans/2026-06-27-分享重构.md b/docs/superpowers/plans/2026-06-27-分享重构.md new file mode 100644 index 0000000..7faa6c3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-分享重构.md @@ -0,0 +1,1009 @@ +# 分享重构(去 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'` 全程一致。