无尘阁日记

无尘阁日记

WPSJS 调用 taskpane 与 dialog 的封装示例
2026-03-23

可以。我直接按“能放进你现有 Vue 工程里”的标准来封。

先说结论:

WPS 侧我建议你封成三层:

一、封装目标

1. UI 层不直接碰 WPS API

你的 Vue 页面里只调用:

  • openTaskPane()

  • openDialog()

  • closeDialog()

  • sendMessageToHost()

  • onMessage()

这样后面你切换真实 WPS API、浏览器调试、补兼容,都只改一层。

2. 页面通信不要依赖“同一个 Vue 实例”

而改成统一消息机制:

  • 优先 BroadcastChannel

  • 其次 localStorage

  • 再加 postMessage 兜底

因为 taskpane 和 dialog 本质是不同网页容器,WPS 官方也明确说 taskpane 是通过网页路径创建,dialog 也是根据 URL 创建 web 页面。(WPS开放平台)

3. TaskPane 做“单例优先”

WPS 有 CreateTaskpaneGetTaskpane 这组能力,说明它本身就支持创建与获取 taskpane。(WPS开放平台)
所以封装时应该优先考虑:

  • 已存在就显示/复用

  • 不存在再创建


二、你可以直接用的封装目录

建议加这几个文件:

src/shared/wps/ ├─ types.ts ├─ runtime.ts ├─ channel.ts ├─ dialog.ts ├─ taskpane.ts ├─ app.ts └─ index.ts

三、核心类型定义

src/shared/wps/types.ts

export type WpsMessageType =   | 'dialog:ready'   | 'dialog:close'   | 'dialog:result'   | 'taskpane:ready'   | 'taskpane:refresh'   | 'auth:success'   | 'auth:logout'   | 'picker:selected'   | 'custom' export interface WpsMessage<T = any> {   id: string   type: WpsMessageType | string   source: 'taskpane' | 'dialog' | 'host' | 'unknown'   target?: 'taskpane' | 'dialog' | 'host' | 'all'   payload?: T   timestamp: number } export interface OpenDialogOptions {   url: string   title?: string   width?: number   height?: number   modal?: boolean   resizable?: boolean   singletonKey?: string   query?: Record<string, string | number | boolean | undefined | null> } export interface OpenTaskPaneOptions {   url: string   title?: string   visible?: boolean   width?: number   singletonKey?: string   query?: Record<string, string | number | boolean | undefined | null> } export interface WpsTaskPaneRef {   visible?: boolean   width?: number   title?: string   show?: () => void   hide?: () => void   close?: () => void   [key: string]: any } export interface WpsDialogRef {   close?: () => void   [key: string]: any } export interface WpsApplicationLike {   CreateTaskpane?: (...args: any[]) => any   GetTaskpane?: (...args: any[]) => any   ShowDialog?: (...args: any[]) => any   alert?: (message: string) => void   confirm?: (message: string) => boolean   [key: string]: any } declare global {   interface Window {     wps?: any     WPS?: any     WpsApplication?: any     Application?: any   } }

四、运行时探测层

这个文件的作用是:

  • 找到宿主里的 WPS 对象

  • 统一判断当前是不是在 WPS 里

  • 非 WPS 环境时自动降级到浏览器调试模式

src/shared/wps/runtime.ts

import type { WpsApplicationLike } from './types' export function getWpsApplication(): WpsApplicationLike | null {   return (     window.wps ||     window.WPS ||     window.WpsApplication ||     window.Application ||     null   ) } export function isWpsRuntime(): boolean {   return !!getWpsApplication() } export function safeAlert(message: string) {   const app = getWpsApplication()   if (app?.alert) {     app.alert(message)     return   }   window.alert(message) } export function safeConfirm(message: string): boolean {   const app = getWpsApplication()   if (app?.confirm) {     return !!app.confirm(message)   }   return window.confirm(message) }

五、统一消息总线

这部分是关键。

因为多个 taskpane / dialog 不是同一个 Vue 实例,所以我们用统一通道来做页面间通信。

设计思路:

1. 优先 BroadcastChannel

现代 WebView 通常支持得不错,使用最干净。

2. 降级 localStorage

兼容性更稳。

3. 顺手支持 postMessage

方便浏览器调试和父子窗口场景。

src/shared/wps/channel.ts

import type { WpsMessage } from './types' const CHANNEL_NAME = 'wps-addin-channel' const STORAGE_KEY = '__wps_addin_channel__' type MessageHandler = (message: WpsMessage) => void let bc: BroadcastChannel | null = null const handlers = new Set<MessageHandler>() let initialized = false function uuid() {   return `${Date.now()}_${Math.random().toString(36).slice(2, 10)}` } export function createMessage<T = any>(   type: string,   payload?: T,   source: WpsMessage['source'] = 'unknown',   target: WpsMessage['target'] = 'all', ): WpsMessage<T> {   return {     id: uuid(),     type,     source,     target,     payload,     timestamp: Date.now(),   } } function dispatch(message: WpsMessage) {   handlers.forEach((fn) => {     try {       fn(message)     } catch (error) {       console.error('[wps-channel] handler error:', error)     }   }) } function init() {   if (initialized) return   initialized = true   if ('BroadcastChannel' in window) {     bc = new BroadcastChannel(CHANNEL_NAME)     bc.onmessage = (event) => {       if (event?.data) dispatch(event.data)     }   }   window.addEventListener('storage', (event) => {     if (event.key !== STORAGE_KEY || !event.newValue) return     try {       const message = JSON.parse(event.newValue) as WpsMessage       dispatch(message)     } catch (error) {       console.error('[wps-channel] storage parse error:', error)     }   })   window.addEventListener('message', (event) => {     const data = event.data     if (!data || typeof data !== 'object') return     if (data.__wps_addin_message__ !== true) return     dispatch(data.message as WpsMessage)   }) } export function emitMessage(message: WpsMessage) {   init()   if (bc) {     bc.postMessage(message)   }   try {     localStorage.setItem(STORAGE_KEY, JSON.stringify(message))   } catch (error) {     console.error('[wps-channel] localStorage error:', error)   }   try {     if (window.opener) {       window.opener.postMessage(         { __wps_addin_message__: true, message },         '*',       )     }     if (window.parent && window.parent !== window) {       window.parent.postMessage(         { __wps_addin_message__: true, message },         '*',       )     }   } catch (error) {     console.error('[wps-channel] postMessage error:', error)   } } export function onMessage(handler: MessageHandler) {   init()   handlers.add(handler)   return () => handlers.delete(handler) }

六、TaskPane 封装

这里我按“单例优先 + 浏览器可调试”来写。

WPS 官方已有 CreateTaskpaneGetTaskpane。(WPS开放平台)

src/shared/wps/taskpane.ts

import { getWpsApplication, isWpsRuntime } from './runtime' import type { OpenTaskPaneOptions, WpsTaskPaneRef } from './types' const taskPaneMap = new Map<string, WpsTaskPaneRef>() function buildUrl(   baseUrl: string,   query?: Record<string, string | number | boolean | undefined | null>, ) {   const url = new URL(baseUrl, window.location.origin)   if (query) {     Object.entries(query).forEach(([key, value]) => {       if (value === undefined || value === null) return       url.searchParams.set(key, String(value))     })   }   return url.toString() } function getCacheKey(options: OpenTaskPaneOptions) {   return options.singletonKey || options.url } export function getTaskPane(singletonKey: string): WpsTaskPaneRef | null {   if (taskPaneMap.has(singletonKey)) {     return taskPaneMap.get(singletonKey) || null   }   const app = getWpsApplication()   if (!app?.GetTaskpane) return null   try {     const pane = app.GetTaskpane(singletonKey)     if (pane) {       taskPaneMap.set(singletonKey, pane)       return pane     }   } catch (error) {     console.warn('[wps-taskpane] GetTaskpane failed:', error)   }   return null } export function openTaskPane(options: OpenTaskPaneOptions): WpsTaskPaneRef | null {   const app = getWpsApplication()   const cacheKey = getCacheKey(options)   const finalUrl = buildUrl(options.url, options.query)   const existed = getTaskPane(cacheKey)   if (existed) {     try {       existed.visible = true       if (typeof existed.show === 'function') existed.show()       if (options.width != null) existed.width = options.width       if (options.title) existed.title = options.title       return existed     } catch (error) {       console.warn('[wps-taskpane] reuse existing pane failed:', error)     }   }   if (!isWpsRuntime() || !app?.CreateTaskpane) {     console.warn('[wps-taskpane] not in WPS runtime, fallback to window.open')     window.open(finalUrl, '_blank')     return null   }   try {     // 注意:     // 官方文档说明 CreateTaskpane 存在,但页面抓取无法稳定拿到完整签名。     // 下面做“宽松调用”,适配不同运行环境。     const pane =       app.CreateTaskpane(finalUrl, options.title || '', options.visible ?? true) ||       app.CreateTaskpane(finalUrl)     if (pane) {       if (options.width != null) pane.width = options.width       if (options.title) pane.title = options.title       if (options.visible != null) pane.visible = options.visible       taskPaneMap.set(cacheKey, pane)       return pane     }   } catch (error) {     console.error('[wps-taskpane] CreateTaskpane failed:', error)   }   return null } export function showTaskPane(singletonKey: string) {   const pane = getTaskPane(singletonKey)   if (!pane) return   try {     pane.visible = true     if (typeof pane.show === 'function') pane.show()   } catch (error) {     console.error('[wps-taskpane] show failed:', error)   } } export function hideTaskPane(singletonKey: string) {   const pane = getTaskPane(singletonKey)   if (!pane) return   try {     pane.visible = false     if (typeof pane.hide === 'function') pane.hide()   } catch (error) {     console.error('[wps-taskpane] hide failed:', error)   } } export function closeTaskPane(singletonKey: string) {   const pane = getTaskPane(singletonKey)   if (!pane) return   try {     if (typeof pane.close === 'function') {       pane.close()     } else {       pane.visible = false     }     taskPaneMap.delete(singletonKey)   } catch (error) {     console.error('[wps-taskpane] close failed:', error)   } }

七、Dialog 封装

WPS 官方说明 ShowDialog 用给定 url、title、width、height 等信息来创建对话框,而且内容本身就是 web 页面。(WPS开放平台)

这里封装目标:

1. 支持 query 传参

2. 支持浏览器降级调试

3. 支持“逻辑单例”

避免一个按钮狂点弹出多个相同 dialog

src/shared/wps/dialog.ts

import { getWpsApplication, isWpsRuntime } from './runtime' import type { OpenDialogOptions, WpsDialogRef } from './types' const dialogMap = new Map<string, WpsDialogRef>() function buildUrl(   baseUrl: string,   query?: Record<string, string | number | boolean | undefined | null>, ) {   const url = new URL(baseUrl, window.location.origin)   if (query) {     Object.entries(query).forEach(([key, value]) => {       if (value === undefined || value === null) return       url.searchParams.set(key, String(value))     })   }   return url.toString() } function getCacheKey(options: OpenDialogOptions) {   return options.singletonKey || options.url } export function openDialog(options: OpenDialogOptions): WpsDialogRef | null {   const app = getWpsApplication()   const finalUrl = buildUrl(options.url, options.query)   const cacheKey = getCacheKey(options)   const existed = dialogMap.get(cacheKey)   if (existed) {     return existed   }   if (!isWpsRuntime() || !app?.ShowDialog) {     console.warn('[wps-dialog] not in WPS runtime, fallback to window.open')     const child = window.open(       finalUrl,       '_blank',       `width=${options.width || 720},height=${options.height || 520}`,     )     const mockRef: WpsDialogRef = {       close: () => child?.close(),       rawWindow: child,     }     dialogMap.set(cacheKey, mockRef)     return mockRef   }   try {     // 注意:     // 官方页面能确认 ShowDialog 存在,但完整签名抓取不稳定。     // 这里做宽松兼容调用。     const dialog =       app.ShowDialog(         finalUrl,         options.title || '',         options.width || 720,         options.height || 520,       ) || app.ShowDialog(finalUrl)     if (dialog) {       dialogMap.set(cacheKey, dialog)       return dialog     }   } catch (error) {     console.error('[wps-dialog] ShowDialog failed:', error)   }   return null } export function closeDialog(singletonKeyOrUrl: string) {   const dialog = dialogMap.get(singletonKeyOrUrl)   if (!dialog) return   try {     if (typeof dialog.close === 'function') {       dialog.close()     }   } catch (error) {     console.error('[wps-dialog] close failed:', error)   } finally {     dialogMap.delete(singletonKeyOrUrl)   } } export function clearDialogRef(singletonKeyOrUrl: string) {   dialogMap.delete(singletonKeyOrUrl) } export function closeSelfDialog() {   try {     window.close()   } catch (error) {     console.error('[wps-dialog] self close failed:', error)   } }

八、对外统一门面

这个文件给你的 Vue 页面直接用。

src/shared/wps/app.ts

import { createMessage, emitMessage, onMessage } from './channel' import { openDialog, closeDialog, closeSelfDialog, clearDialogRef } from './dialog' import { openTaskPane, showTaskPane, hideTaskPane, closeTaskPane, getTaskPane } from './taskpane' import { safeAlert, safeConfirm, isWpsRuntime } from './runtime' export {   openDialog,   closeDialog,   closeSelfDialog,   clearDialogRef,   openTaskPane,   showTaskPane,   hideTaskPane,   closeTaskPane,   getTaskPane,   safeAlert,   safeConfirm,   isWpsRuntime,   createMessage,   emitMessage,   onMessage, }

src/shared/wps/index.ts

export * from './types' export * from './runtime' export * from './channel' export * from './dialog' export * from './taskpane' export * from './app'

九、Vue 页面里怎么用

下面我直接给你最实用的用法。


十、TaskPane 页面示例

src/pages/taskpane-home/App.vue

<template>   <div>     <h1>TaskPane 首页</h1>     <div>       <button @click="handleOpenLogin">打开登录弹窗</button>       <button @click="handleOpenPicker">打开选择器弹窗</button>       <button @click="handleOpenSettings">打开设置窗格</button>     </div>     <div>       <p>token:{{ token || '未登录' }}</p>       <p>选择结果:{{ pickedValue || '暂无' }}</p>       <p>最后一条消息:{{ lastMessage }}</p>     </div>   </div> </template> <script setup> import { onMounted, onUnmounted, ref } from 'vue' import {   openDialog,   openTaskPane,   onMessage, } from '@/shared/wps' const token = ref('') const pickedValue = ref('') const lastMessage = ref('') function syncFromStorage() {   token.value = localStorage.getItem('wps_token') || ''   pickedValue.value = localStorage.getItem('wps_picked_value') || '' } function handleOpenLogin() {   openDialog({     url: '/dialog-login.html',     title: '登录',     width: 520,     height: 360,     singletonKey: 'dialog-login',     query: {       from: 'taskpane-home',     },   }) } function handleOpenPicker() {   openDialog({     url: '/dialog-picker.html',     title: '选择器',     width: 640,     height: 480,     singletonKey: 'dialog-picker',     query: {       mode: 'single',     },   }) } function handleOpenSettings() {   openTaskPane({     url: '/taskpane-settings.html',     title: '设置',     singletonKey: 'taskpane-settings',     visible: true,     width: 360,   }) } let off: null | (() => void) = null onMounted(() => {   syncFromStorage()   off = onMessage((message) => {     lastMessage.value = `${message.type} @ ${new Date(message.timestamp).toLocaleTimeString()}`     if (message.type === 'auth:success') {       syncFromStorage()     }     if (message.type === 'picker:selected') {       syncFromStorage()     }     if (message.type === 'dialog:close') {       syncFromStorage()     }   }) }) onUnmounted(() => {   off?.() }) </script> <style scoped> .page {   padding: 16px; } .btns {   display: flex;   gap: 12px;   flex-wrap: wrap;   margin: 16px 0; } button {   padding: 8px 14px;   cursor: pointer; } .card {   border: 1px solid #ddd;   border-radius: 8px;   padding: 12px; } </style>

十一、登录 Dialog 页面示例

src/pages/dialog-login/App.vue

<template>   <div>     <h2>登录</h2>     <div>       <label>账号:</label>       <input v-model="username" />     </div>     <div>       <label>密码:</label>       <input v-model="password" type="password" />     </div>     <div>       <button @click="handleSubmit">登录</button>       <button @click="handleClose">关闭</button>     </div>   </div> </template> <script setup> import {   createMessage,   emitMessage,   closeSelfDialog, } from '@/shared/wps' import { onMounted } from 'vue' let username = $ref('') let password = $ref('') onMounted(() => {   emitMessage(     createMessage('dialog:ready', { page: 'dialog-login' }, 'dialog', 'host'),   ) }) function handleSubmit() {   const token = `token_${Date.now()}`   localStorage.setItem('wps_token', token)   localStorage.setItem('wps_user', username || 'anonymous')   emitMessage(     createMessage(       'auth:success',       { token, user: username || 'anonymous' },       'dialog',       'host',     ),   )   emitMessage(     createMessage('dialog:close', { key: 'dialog-login' }, 'dialog', 'host'),   )   handleClose() } function handleClose() {   closeSelfDialog() } </script> <style scoped> .page {   padding: 20px; } .form-item {   margin-bottom: 12px; } label {   display: inline-block;   width: 60px; } input {   width: 220px;   padding: 6px 8px; } .btns {   margin-top: 16px;   display: flex;   gap: 12px; } button {   padding: 8px 14px; } </style>

十二、选择器 Dialog 页面示例

src/pages/dialog-picker/App.vue

<template>   <div>     <h2>选择模板</h2>     <ul>       <li v-for="item in list" :key="item.value">         <button @click="handlePick(item.value)">{{ item.label }}</button>       </li>     </ul>     <div>       <button @click="handleClose">关闭</button>     </div>   </div> </template> <script setup> import {   createMessage,   emitMessage,   closeSelfDialog, } from '@/shared/wps' import { onMounted } from 'vue' const list = [   { label: '模板 A', value: 'A' },   { label: '模板 B', value: 'B' },   { label: '模板 C', value: 'C' }, ] onMounted(() => {   emitMessage(     createMessage('dialog:ready', { page: 'dialog-picker' }, 'dialog', 'host'),   ) }) function handlePick(value: string) {   localStorage.setItem('wps_picked_value', value)   emitMessage(     createMessage(       'picker:selected',       { value },       'dialog',       'host',     ),   )   emitMessage(     createMessage('dialog:close', { key: 'dialog-picker' }, 'dialog', 'host'),   )   handleClose() } function handleClose() {   closeSelfDialog() } </script> <style scoped> .page {   padding: 20px; } ul {   list-style: none;   padding-left: 0; } li {   margin-bottom: 10px; } button {   padding: 8px 14px; } .btns {   margin-top: 16px; } </style>

十三、设置 TaskPane 页面示例

src/pages/taskpane-settings/App.vue

<template>   <div>     <h2>设置</h2>     <div>       <label>API 地址:</label>       <input v-model="apiBaseUrl" />     </div>     <div>       <label>主题:</label>       <select v-model="theme">         <option value="light">浅色</option>         <option value="dark">深色</option>       </select>     </div>     <div>       <button @click="handleSave">保存</button>     </div>   </div> </template> <script setup> import { onMounted } from 'vue' import { createMessage, emitMessage } from '@/shared/wps' let apiBaseUrl = $ref('') let theme = $ref('light') onMounted(() => {   apiBaseUrl = localStorage.getItem('wps_api_base_url') || ''   theme = localStorage.getItem('wps_theme') || 'light'   emitMessage(     createMessage('taskpane:ready', { page: 'taskpane-settings' }, 'taskpane', 'host'),   ) }) function handleSave() {   localStorage.setItem('wps_api_base_url', apiBaseUrl)   localStorage.setItem('wps_theme', theme)   emitMessage(     createMessage(       'taskpane:refresh',       { page: 'taskpane-settings', saved: true },       'taskpane',       'host',     ),   )   alert('保存成功') } </script> <style scoped> .page {   padding: 16px; } .form-item {   margin-bottom: 12px; } label {   display: inline-block;   width: 80px; } input, select {   min-width: 220px;   padding: 6px 8px; } .btns {   margin-top: 16px; } button {   padding: 8px 14px; } </style>

十四、这套封装的真实价值

不是“把 API 包一层”这么简单,而是统一了三件事。

1. 打开方式统一

你业务层永远只写:

openDialog({...}) openTaskPane({...})

不用到处写 app.ShowDialog(...)app.CreateTaskpane(...)

2. 通信方式统一

你业务层永远只写:

emitMessage(createMessage(...)) onMessage(...)

而不需要纠结:

  • 这个页面是不是 opener 打开的

  • 这个环境支不支持 postMessage

  • storage 事件能不能收到

3. 浏览器调试统一

在非 WPS 环境下,自动降级成:

  • window.open

  • window.close

这样你写 Vue 页面时不必每次都先进 WPS 宿主。


十五、我替你提前指出两个关键现实问题

这里我必须跟你说清楚,不乱吹。

1. 官方能确认方法存在,但完整签名抓取不稳定

我查到的 WPS 官方文档可以确认这些方法存在:

  • Application.CreateTaskpane

  • Application.GetTaskpane

  • Application.ShowDialog

并且官方摘要说明 taskpane 是通过网页路径创建,dialog 是根据 URL 创建 web 页面。(WPS开放平台)

但官方页面在当前抓取环境里没有稳定吐出完整方法签名,所以我在封装里采用了“宽松调用 + 浏览器降级”策略。
这意味着你第一次接入真实 WPS Runtime 时,可能只需要把这两行细调一下:

app.CreateTaskpane(...) app.ShowDialog(...)

整体架构不用改。

2. Dialog 关闭方式可能跟宿主版本有关

我现在用了:

dialog.close?.() window.close()

这已经是最稳的工程化写法。
如果你本地接 WPS 时发现它要求特定关闭 API,我们只需要补在 dialog.ts 一处。


十六、我建议你下一步这样做

最实用的顺序是:

第一步

先把上面这 7 个封装文件放进去。

第二步

先在浏览器里跑:

  • taskpane-home.html

  • dialog-login.html

  • dialog-picker.html

  • taskpane-settings.html

看消息回传是否通。

第三步

再在 WPS 里把 CreateTaskpane / ShowDialog 的真实参数形式对上。

因为官方能确认 API 存在,但你本地版本的具体调用细节可能略有差异。(WPS开放平台)


十七、最短总结

你现在这套封装已经满足:

  • 打开 taskpane

  • 打开 dialog

  • 关闭 dialog

  • 页面间传参

  • dialog 回传结果给 taskpane

  • 浏览器/WPS 双环境可调试

而且思路是对的:

WPS 插件整体是多页面容器,封装层要围绕“页面打开 + 页面通信 + 宿主兼容”来设计,而不是围绕一个总 SPA 来设计。