Skip to content

一、背景介绍

开放能力是协作的核心竞争力之一,但对于复杂的客户需求往往无法通过简单配置完成,开分支定制开发又容易导致后续分支难以合并、维护。插件化的思路是提供稳定的产品基座和拓展API,对非标准的产品功能使用插件能力来实现,减少对基座的入侵。同时又具有如下优势:

  • 灵活的定制能力,适用于多变的定制需求
  • 基于插件开发的业务功能更新迭代不随着协作版本,插件随时可以更新,快速响应变化
  • 在原有的功能基础上进行定制开发,无需新开一套功能
  • 插件API可以快速更新,不影响主版本的更新迭代,绝大多数情况下彼此也互不干扰
  • 插件的开发不依赖金山团队,协作产研团队仅提供能力和指导
  • 客户自己的研发也能开发插件或者引入服务商来做插件开发

插件化能力边界

通过基座提供插件开发API

  • 插件的定制需求依赖于基座提供的插件API。开发者在实现功能需求时需要参考对应版本插件API文档,看是否基座提供了相关的实现能力API,通过调用插件API来实现功能,否则需要等待基座升级(基座二开能力更新,由金山产研侧开发完成,外部开发者无开发权限)以支持该功能。

image.png

视图限制

  • 在视图层面有一些限制。尽管协作支持通过调用插件API的方式来添加新的视图元素(如侧边栏tab、消息卡片右键菜单、自定义搜索内容等),但对于这些可扩展元素的修改范围和方式有所限制。为了确保协作界面的整体统一,以避免插件对原有视图的破坏,暂时不开放对这些可扩展区域进行自定义渲染。相反,插件提供需要渲染的数据和点击行为,而基座负责界面的绘制和广播界面行为事件。

插件不支持关闭或者开启标准功能

  • 现有产品功能无法屏蔽。目前暂不支持通过插件的方式关闭或不使用现有的标准功能。插件是用来对协作产品进行功能的扩展,而不是关闭或禁用现有的产品功能。功能开关是通过打包配置来实现的。

插件限制

  • 集成原生SDK的限制。插件是使用JavaScript技术实现的,可以在浏览器或Electron环境中运行。然而,要在插件中集成原生语言实现的SDK,需要通过协作Node类型插件进行集成,并且该SDK只能在Electron端运行。如果想在插件中使用原生语言实现的SDK,需要由SDK供应商提供适用于Node.js环境的*.node动态链接库。或者,需要知道明确的导出方法,自己编写node-ffi实现。

插件原理

协作插件采用了与Web前端相关的技术栈(JavaScript、HTML、CSS、Node.js)进行开发。本质是给插件在特定的时机(即生命周期回调)提供数据注入的能力。

插件类型

  • 协作的定制总体分为两大层面的定制:视图层和客户端原生层。针对这种需求背景,插件被设计为服务于这两大方面的插件类型,使用Web插件负责视图层,使用Node插件负责客户端原生层。

web插件

  • Web插件支持Electron渲染进程环境和浏览器环境,承接视图相关的定制需求,不限制Web视图框架,在构建和加载时,依赖webpack5模块联邦的能力,多个Web插件可以和基座实现资源共享。

Node插件

  • Node插件用于扩展客户端能力,例如集成第三方原生SDK(如截图、会议等)和对Electron环境进行定制和拓展。此类插件在Node.js进程中运行,并具有系统级的原生操作能力,不支持浏览器环境。

Commands通信机制

  • 为了解决插件之间的通信问题,例如A插件需要调用B插件的内置行为,或者Web插件需要调用Node插件的功能,甚至是插件调用基座的能力。在这些情况下,我们使用了command机制的方式来实现这些场景。插件API提供了commands机制,实现了基座与插件之间的通信,以及不同类型插件之间的通信。这意味着Web插件和Node插件可以相互调用commands,以支持在各自环境中调用定制的行为。

image.png

  • 虽然Web插件和Node插件运行在不同的进程,但插件开发者在注册和调用commands时无须关心这些细节,基座使用了远程服务的方式抹平了这些差异,减轻插件开发者的负担。

  • 除了用于插件之间的通信,commands机制还适用于自定义事件。自定义事件可以包括DOM元素上的点击事件、鼠标悬停事件等,也可以包括非DOM元素上的行为事件。例如,插件需要调用第三方SDK的api,可以通过command的方式封装SDK的api提供给其它插件使用,在Node插件中一般都会使用这种方式进行第三方api封装和暴露,这有助于代码的维护和管理。通过commands机制,插件可以灵活地定义和触发自定义事件,同时实现插件内部和插件之间的交互,为功能的扩展和定制提供了更多的可能性。

开发流程

基本术语

基座包

  • 指不含插件的底座包,提供插件开发的能力,并为插件开发人员提供二开API,用于插件开发和联调阶段。在rc 4.6.0之后的协作包即为基座包。

接口描述文件

  • 接口使用ksxz进行描述和定义,默认集成在插件模板工程内部,用于辅助插件开发,可提供接口声明、接口参数类型检测、提供代码提示等能力。注意:接口与基座是强绑定关系,高版本的基座拥有比低版本更多的可用接口。

插件工程

开发者工具

  • 开发者工具提供便捷的插件入口配置和启动基座的能力,以及对应的进程调试入口,简化上手难度。

二次打包

  • 插件开发完成后,开发者需要在本地构建生产环境的插件包,然后通过二次打包工具与基座包进行集成,完成基座和插件的合并和功能交付。二次打包可采用以下二种机制进行,一种是使用工具,一种是金山产研帮忙处理。

image.png

二次打包工程:见对应构建机的package_woa_delivery_integrate_extensions项目

插件开发教程

接口能力

  • 插件开发最重要的点在于理解基座有哪些插件api,支持哪方面的定制能力,以及如何根据这些api来实现相关的需求,详细接口支持可查看插件开发支持能力API章节。

拓展说明

  • 界面拓展1.提供侧边栏、顶栏菜单、右键菜单等视图的定制:

image.png

2.主视图定制:

image.png

  • command、内置Command

下面是command使用的例子:

-- 在web插件注册了一个事件

java
ksxz.commands.registerCommand(
    'custom.tab.click',
    () => {
      console.log('custom command')
    }
)

-- js手动调用

js
ksxz.commands.executeCommand("custom.tab.click", ...args);

-- 视图自定义调用

js
// 侧边栏自定义点击事件
ksxz.ui.registerSidebarTab({
    ...
    postCommand: 'custom.tab.click'
 })

// 右键菜单
ksxz.ui.registerMenuItem({
    ...
    command: 'custom.tab.click'
 })

// 顶栏菜单
ksxz.ui.registerTabbarMenuItem({
    ...
    command: 'custom.tab.click'
 })

在node插件调用,和web插件调用方式一致,开发者无须做特殊处理,相反,web插件调用node插件command方式也一致 举例使用场景:在视图操作中,需要通过web插件调用通过node插件集成sdk的api

js
ksxz.commands.executeCommand("custom.tab.click", ...args);

此外,基座目前集成了一批内置commands,插件可以直接调用,快速实现需求。

3.hook 基座提供的钩子事件,通过ksxz.client.onXXX方式暴露,例如 onAppStartup、onBeforeAppQuit等。在适当的的钩子事件,可以做一些处理,比如在应用退出时做数据清理等等。

js
ksxz.client.onBeforeAppQuit(() => {
  // 应用退出前的钩子
  // 可以用于解绑某些事件行为
});

4.支持在Webview页面(三方页面)内调用插件API 在插件开发中,经常会使用Webview加载第三方页面。这些第三方页面可能有使用插件API的需求,例如在Webview页面中进行视图跳转或调用命令等。在协作中,我们已经实现了对Webview页面注入插件API的功能,并对使用代理方法进行了自动桥接和转发,从而减少开发人员的工作量。现在,插件开发者可以在Webview页面中无缝地调用插件API,实现丰富的功能扩展。

js
// webview 页面内部的代码
// 拿到 ksxz api 对象
const ksxz = window.acquireKsxzApi();
// 调用 api
ksxz.client.openUrl();

二次开发举例

示例1:侧边栏添加页面入口。如邮件、工作台

  • 场景:客户有自己的业务系统希望快速集成到我们客户端中,但又不希望只在我们的内置工作台放入口,通过插件API可以给侧边栏添加快捷入口。

image.png

插件使用API注册工作台示例:

js
import ksxz from "ksxz";

// 注册工作台侧边栏 tab
ksxz.ui.registerSidebarTab({
  id: "ksxz_web_ext.workspace.tab",
  viewId: "ksxz_web_ext.workspace.view",
  name: "工作台",
  icon: "ksxz_web_ext-icon-workspace-app",
  activeIcon: "ksxz_web_ext-icon-workspace-active",
  order: 100,
});

// 注册工作台tab对应的主视图页面
ksxz.ui.registerMainViewDisplayer({
  id: "ksxz_web_ext.view",
  getWebviewUrl() {
    // 可以是在线页面
    return "https://xz.wps.cn";
    // 可以是本地页面
    return extensionPath + "pages/app.html";
  },
});

示例2:调整侧边栏图标顺序或屏蔽某些图标

场景:客户希望把自己业务放在前面,对于低频的入口,如“我的”希望收起。可以通过 ksxz.ui.registerSidebarTabProvider 接口,自定义左侧边栏图标顺序和显示个数。 使用API自定义侧边栏tab行为示例:

js
import ksxz from 'ksxz'

// 基座内置 tab id
export enum ESidebarTabId {
  MESSAGES = 'messages',
  APP = 'app',
  DOCS = 'docs',
  ADDRESS_BOOK = 'addressBook',
  PERSONAL = 'personal'
}

class SidebarTabProvider implements ksxz.ui.ISidebarTabProvider {
    // 控制展开的 tab,超出数量则收纳到更多 tab 栏目里面
    expandedTabCount = 4

    getTabs(tabs: ksxz.ui.ISidebarTab[]) {
      // 使用 filter 方式屏蔽内置的 tab
      let newTabs = tabs.slice()
      newTabs = newTabs.filter(
        (tab) =>
          // 隐藏“我的”tab
          tab.id !== ESidebarTabId.PERSONAL
      )

      // 修改默认行为
      newTabs = newTabs.map((tab) => {
        if (tab.id === ESidebarTab.MESSAGES) {
          // 修改 order 实现排序
          tab.order = 2.1
          // 修改 icon 实现自定义图标
          tab.icon = 'ksxz_web_ext-xxx'
          tab.activeIcon = 'ksxz_web_ext-xxx-active'
        }
        return tab
      })

      return newTabs
    }
 }

ksxz.ui.registerSidebarTabProvider(new SidebarTabProvider())

示例3:搜索中加入用户自定搜索(第三方搜索结果)

场景:客户希望在搜索窗口中添加自定义分类,搜索常用业务系统中的数据,提高便捷性

image.png

image.png

js
import ksxz from 'ksxz'

const wait = (ms: number) => {
    return new Promise((resolve) => setTimeout(resolve, ms))
  }

const createMockSearcher = (category: string, withAvatar = true) => {
const total = Math.ceil(50 + Math.random() * 50)

    return async (
      keyword: string,
      tag: string | number | undefined,
      count: number,
      offset: number,
      onCancel: (cb: () => void) => void
    ) => {
      let cancelled = false
      onCancel(() => (cancelled = true))

      console.log(
        `ext search satrt[${category}]: keyword=${keyword}, count=${count}, offset=${offset}, total=${total}`
      )

      await wait(500 + Math.random() * 500)

      if (cancelled) {
        throw new Error(
          `ext search cancelled[${category}]: keyword=${keyword}, count=${count}, offset=${offset}, total=${total}`
        )
      }

      const data = new Array(Math.min(count, total - offset)).fill(0).map((_, i) => {
        const id = i + offset

        return {
          id: id,
          avatar: withAvatar ? require('@/assets/logo.png') : void 0,
          title: `${category}: <em>${keyword}</em> ${id}`,
          subtitle: `id: ${id}\ntag: ${tag}\n点击动作: ${
            id === 0 ? '在外部浏览器打开链接' : id === 1 ? '在主窗口标签页打开链接' : '弹出提示框'
          }`,
          time: Date.now() - 1000 * id
        }
      })

      console.log(
        `ext search end[${category}]: keyword=${keyword}, count=${count}, offset=${offset}, total=${total}`
      )

      return {
        data,
        total,
        hasNext: offset + data.length < total
      }
    }
 }

const createMixedMockSearcher = (category: string, withAvatar?: boolean) => {
const searchers: Record<string, ksxz.search.ISearchCategoryOptions['search']> = {}

    return (
      keyword: string,
      tag: string | number | undefined,
      count: number,
      offset: number,
      onCancel: (cb: () => void) => void
    ) => {
      tag = String(tag)
      const search = searchers[tag] || (searchers[tag] = createMockSearcher(`${category}-${tag}`, withAvatar))

      return search(keyword, tag, count, offset, onCancel)
    }
}

const onClick = (item: ksxz.search.ISearchData) => {
    switch ((item as ksxz.search.ISearchData & Record<'id', number>).id) {
      case 0:
        ksxz.client.openUrl('https://www.baidu.com', 'browser')
        break
      case 1:
        ksxz.client.openUrl('https://www.baidu.com', 'maintab')
        break
      default:
        alert(`点击了「${item.title}」`)
        break
    }
}

ksxz.search.registerCategory({
  category: 'custom1',
  label: '普通搜索',
  search: createMockSearcher('自定义搜索1', false),
  onDidClickItem: onClick
})

ksxz.search.registerCategory({
  category: 'custom2',
  label: '子分类搜索',
  tags: [
    { id: 0, label: '子分类1' },
    { id: 1, label: '子分类2' }
  ],
  search: createMixedMockSearcher('子分类搜索', true),
  onDidClickItem: onClick
})

示例4:接入客户会议系统(会议SDK)

场景:客户有自己的会议SDK,不使用金山会议,需要把客户的会议SDK通过插件的方式接入进来

  • node插件 在node插件集成客户会议sdk,把sdk包放到node插件目录里面

image.png

将初始化sdk逻辑封装到 initSDK 里面

js
function initSDK() {
  const isMac = process.platform == "darwin";
  const relativePath = isDev ? "../" : "./";
  // 根据不同平台,初始化对应平台的 sdk
  const sdkPath = isMac
    ? path.resolve(__dirname, relativePath, "sdk/mac/Frameworks")
    : path.resolve(__dirname, relativePath, "sdk/windows/win32");
  const TangMeeting = requireFn(path.join(sdkPath, "tangmeeting_ui.node"));
  const tangMeeting = new TangMeeting.TangMeetingWrapper();
  tangMeeting.initialize();
}

在插件入口中初始化sdk,当然,也可以延迟加载

js
import ksxz from 'ksxz'

export function activate(context: ksxz.IExtensionContext) {
  initSdk();
}

通过 ksxz.commands 暴露SDK的接口

js
import ksxz from "ksxz";

// 封装 SDK API 到 commands 中
// web 插件调用 'ksxz_node_ext.joinMeeting' 调起 SDK
ksxz.commands.registerCommand("ksxz_node_ext.joinMeeting", () => {
  SDK.joinMeeting({});
});
  • 注册编辑器工具栏,选择会议人员

image.png

image.png

js
import ksxz from "ksxz";

ksxz.ui.registerMenuItem({
  viewletId: "chat-editor-toolbar",
  id: "ext.toolbar.meeting",
  label: "会议",
  icon: require("@/assets/meeting.svg"),
  when: "activeChatType === 2",
  children: [
    {
      id: "ext.toolbar.meeting.callAll",
      label: "所有人",
      command: "ext.command.callAll",
    },
    {
      id: "ext.toolbar.meeting.selectUser",
      label: "选择参会人",
      command: "ext.command.selectGroupUser",
    },
  ],
});

ksxz.commands.registerCommand("ext.command.callAll", () =>
  console.log("呼叫所有人")
);
ksxz.commands.registerCommand("ext.command.selectGroupUser", async () => {
  const chat = await ksxz.ui.getActiveChat();
  if (!chat) {
    return;
  }

  const users = await ksxz.ui.selectUsers({
    chatid: chat.id,
    title: "请选择参会人",
    isMultiple: true,
    isThirdUserId: true,
    maxCount: 300,
  });

  // 拿到对应相应 users,调用步骤1中 node 插件注册的commands,完成加入会议流程
  ksxz.commands.executeCommand("ksxz_node_ext.joinMeeting");
});
  • 自定义deeplink 通过自定义deeplink,可以实现应用机器人卡片、浏览器打开链接、点击消息文本链接三种方式加入会议

image.png

image.png

image.png

例如,客户前后端定义的加入会议deeplink为ksoxz://xz.wps.cn/custom/joinMeeting 使用API注册/custom/joinMeeting行为处理器

js
import ksxz from 'ksxz'

ksxz.client.registerDeeplink('/custom/joinMeeting', function (query: object) {
   // 点击加入会议
   ksxz.commands.executeCommand('ksxz_node_ext.joinMeeting')
})
  • 处理websocket消息

如果客户端的服务端使用协作协作中台接口进行消息推送的会议呼叫,那么客户端需要使用插件来处理第三方websocket推送的消息。 使用API处理第三方ws推送消息:

js
import ksxz from "ksxz";

ksxz.client.onDidThirdCmdMsgPush((data) => {
  // 透传服务端自定义data
  console.log(data);
});

示例5:顶部添加快捷功能入口

场景:一些高频使用的功能,客户希望能够放在顶部,方便使用。

image.png

image.png

js
import ksxz from "ksxz";

// 注册顶部导航栏图标按钮
ksxz.ui.registerTabbarAction({
  id: "caixin.tabbar.action",
  label: "自定义图标1",
  icon: require("@/assets/logo.png"),
  command: "ext.command.alert",
});

// 注册顶部导航栏更多菜单子项
ksxz.ui.registerTabbarMenuItem({
  id: "caixin.tabbar.menu",
  label: "创建群聊",
  icon: require("@/assets/logo.png"),
  command: "ext.command.createGroupChat",
});

// 注册上面按钮对应的 commands
ksxz.commands.registerCommand("ext.command.alert", () => alert("1"));
ksxz.commands.registerCommand("ext.command.createGroupChat", () =>
  ksxz.ui.createGroupChat()
);

示例6:添加主题

客户侧希望协作能够体现本企业的元素,提高员工使用认同感 通过插件API注册主题色

js
import ksxz from "ksxz";

ksxz.ui.registerTheme({
  id: "ext.theme.green",
  label: "插件:孔雀绿",
  conf: {
    "theme.thumbnail.icon.color": "var(--kd-color-icon-white)",
    "theme.bg": "#257355",

    "sidebar.icon.color": "var(--kd-color-icon-white)",
    "sidebar.icon.color.active": "var(--kd-color-icon-white)",
    "sidebar.title.color": "var(--kd-color-icon-white)",
    "sidebar.account.color": "rgba(255, 255, 255, 0.7)",
    "sidebar.account.color.hover": "var(--kd-color-text-white)",
    "sidebar.account.color.active": "var(--kd-color-text-white)",
    "sidebar.account.bg": "rgba(255, 255, 255, 0.1)",
    "sidebar.account.bg.hover": "rgba(255, 255, 255, 0.08)",
    "sidebar.account.bg.active": "rgba(255, 255, 255, 0.1)",
    "sidebar.menu.bg.hover": "rgba(255, 255, 255, 0.08)",
    "sidebar.menu.bg.active": "rgba(255, 255, 255, 0.08)",
    "sidebar.menu.opacity": "0.7",
    "sidebar.menu.opacity.active": "1",

    "tabbar.tab.bg": "transparent",
    "tabbar.tab.bg.hover": "#367e62",
    "tabbar.tab.bg.active": "#488970",
    "tabbar.divider.color": "rgba(245, 245, 245, 0.14)",
    "tabbar.tab.title.color": "var(--kd-color-text-white)",
    "tabbar.tab.title.color.active": "var(--kd-color-text-white)",
    "tabbar.tab.icon.color": "rgba(255, 255, 255, 0.6)",
    "tabbar.tab.button.color": "rgba(255, 255, 255, 0.7)",
    "tabbar.tab.button.color.active": "rgba(255, 255, 255, 0.7)",
    "tabbar.tab.button.bg.hover": "rgba(255, 255, 255, 0.08)",
    "tabbar.tab.button.bg.hover.active": "rgba(255, 255, 255, 0.16)",
  },
});

示例7:集成用户通讯录

场景:客户开发了自己的通讯录页面,并希望通过侧边栏方式集成进来,并在页面内选择人员发起聊天

image.png 使用示例1的方式集成侧边栏tab和webview页面,这里不重复举例 在webview页面内,调用插件API的方式

js
// webview 页面内部的代码
// 拿到 ksxz api 对象
const ksxz = window.acquireKsxzApi();
// 调用 api
const chatId = await ksxz.im.createSingleChat({
  user: {
    id: "",
    name: "",
  },
  isThirdUserId: true,
});
chatId && (await ksxz.client.openChat(chatId));