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

37 KiB
Raw Blame History

分享重构(去 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 模块配置 改:querySchemesmqqapi/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 常量后追加:

  /** 请求弹出分享指引窗(渠道选定后,由 ShareProvider 发,BridgeGameContainer 弹窗)。 */
  static readonly SHOW_GUIDE: string = 'share.showGuide';
  • Step 2: 在文件末尾追加两个载荷 interface
/** 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):

export { ShareEvents, SharePanelRequest, SharePanelResult, ShareGuideRequest, ShareGuideResult, PhotoUploadPayload }
  from './src/main/ets/event/ShareEvents';
  • Step 4: 编译验证

Run: devecocli build Expected: BUILD SUCCESSFUL

  • Step 5: 提交
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

把:

    "querySchemes": [
      "weixin"
    ],

改为:

    "querySchemes": [
      "weixin",
      "mqqapi",
      "snssdk1128"
    ],
  • Step 2: 编译验证

Run: devecocli build Expected: BUILD SUCCESSFUL

  • Step 3: 提交
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

/**
 * 分享文本拼接(纯函数,可单测)。
 * 规则(对齐原工程 + 用户确认):`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 末尾导出(供单测)
export { buildShareText } from './src/main/ets/share/ShareText';
  • Step 3: 编译验证

Run: devecocli build Expected: BUILD SUCCESSFUL

  • Step 4: 提交
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: 整文件替换为
/**
 * 微信开放平台 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: 整文件替换为

/**
 * 分享能力(契约 §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 一起)
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: 新建组件

/**
 * 分享指引弹窗(渠道选定后弹出)。纯展示组件:动作经 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: 提交
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.ets4 处:import / 状态 / 订阅+方法 / 渲染+返回键)

  • Step 1: 扩充 import

把第 8-9 行的 common import 追加 ShareGuideRequest, ShareGuideResult

import { AppEnv, EventBus, EventPayload, NavEvents, OpenGenericWebPayload, SwitchGamePayload,
  BackGamePayload, ShareEvents, SharePanelRequest, SharePanelResult, ShareGuideRequest, ShareGuideResult, Logger } from 'common';

在第 12 行 import { SharePanel } 后新增:

import { ShareGuideDialog } from '../components/ShareGuideDialog';
  • Step 2: 新增指引窗状态(紧接现有 shareResultEvent 定义之后,约第 54 行后)
  /** 分享指引窗显隐(渠道选定后叠在 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 行)
    this.cancels.push(EventBus.on(ShareEvents.SHOW_GUIDE, (p?: EventPayload) => this.showShareGuide(p)));
  • Step 4: 新增两个方法(紧接现有 emitShareResult() 方法之后,约第 147 行)
  /** 收到 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 行后)
        // 分享指引窗(顶层叠加;渠道选定后弹,选动作/取消 → 一次性回投 ShareProvider
        if (this.shareGuideVisible) {
          ShareGuideDialog({
            platform: this.shareGuidePlatform,
            contentKind: this.shareGuideKind,
            onPick: (action: string) => this.emitShareGuideResult(action),
          })
        }
  • Step 6: 返回键关闭指引窗(在 onBackPressedif (this.sharePanelVisible) {...} 块之前,约第 401 行前)
      // 指引窗打开时,返回键关闭并按取消回投
      if (this.shareGuideVisible) {
        this.emitShareGuideResult('cancel');
        return true;
      }
  • Step 7: 编译验证

Run: devecocli build Expected: BUILD SUCCESSFUL

  • Step 8: 提交
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: 新建测试文件
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() 同级):

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: 提交
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.mdT-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: 提交

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 契约零改动 → 全程未改 contractshandler/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' 全程一致。