无尘阁日记

无尘阁日记

WPSJS + Vue 的推荐工程目录结构 + Vite 多入口配置示例
2026-03-23

下面我直接给你一套能落地开工的方案。

目标很明确:

WPSJS + Vue + Vite
做成:

  • 多个 taskpane

  • 多个 dialog

  • 公共代码复用

  • 每个页面独立挂载

  • 后期方便扩展

我按“工程结构、打包配置、页面通信、开发建议、示例代码”五部分给你。


一、推荐工程结构

这是我最建议你的目录结构。

wps-vue-addin/ ├─ public/ │  ├─ icons/ │  └─ manifest/ │     └─ ribbon.xml ├─ src/ │  ├─ shared/ │  │  ├─ api/ │  │  │  └─ index.ts │  │  ├─ components/ │  │  │  └─ AppCard.vue │  │  ├─ composables/ │  │  │  └─ useUser.ts │  │  ├─ stores/ │  │  │  └─ app.ts │  │  ├─ styles/ │  │  │  ├─ reset.css │  │  │  └─ common.css │  │  ├─ types/ │  │  │  └─ global.d.ts │  │  └─ utils/ │  │     ├─ storage.ts │  │     ├─ wps.ts │  │     └─ bus.ts │  │ │  ├─ pages/ │  │  ├─ taskpane-home/ │  │  │  ├─ App.vue │  │  │  └─ main.ts │  │  ├─ taskpane-settings/ │  │  │  ├─ App.vue │  │  │  └─ main.ts │  │  ├─ dialog-login/ │  │  │  ├─ App.vue │  │  │  └─ main.ts │  │  └─ dialog-picker/ │  │     ├─ App.vue │  │     └─ main.ts │  │ │  ├─ entry/ │  │  ├─ taskpane-home.html │  │  ├─ taskpane-settings.html │  │  ├─ dialog-login.html │  │  └─ dialog-picker.html │  │ │  └─ env.d.ts │ ├─ vite.config.ts ├─ tsconfig.json ├─ package.json └─ index.html

二、这套结构的设计思路

1. pages 目录

这里放每一个独立页面的 Vue 应用。

也就是说:

  • 一个 taskpane = 一个 Vue 根应用

  • 一个 dialog = 一个 Vue 根应用

例如:

  • taskpane-home/main.ts

  • dialog-login/main.ts

它们彼此独立。


2. entry 目录

这里放每个页面对应的 html 入口。

比如:

  • taskpane-home.html

  • dialog-login.html

WPS 打开 taskpane 或 dialog 时,实际上要指向某个具体 URL。
所以这些 html 入口非常适合跟 WPS 的页面地址一一对应。


3. shared 目录

这里是所有页面共用的东西。

例如:

  • 通用组件

  • API 封装

  • 本地存储

  • 与 WPS 交互的工具方法

  • 类型定义

  • 公共样式

这样既保证多页面独立,又不浪费代码复用。


三、Vite 多入口配置

下面是核心。

vite.config.ts

import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'path' function resolvePath(p: string) {   return path.resolve(__dirname, p) } export default defineConfig({   plugins: [vue()],   resolve: {     alias: {       '@': resolvePath('src'),     },   },   server: {     port: 5173,     host: '0.0.0.0',   },   build: {     outDir: 'dist',     rollupOptions: {       input: {         taskpaneHome: resolvePath('src/entry/taskpane-home.html'),         taskpaneSettings: resolvePath('src/entry/taskpane-settings.html'),         dialogLogin: resolvePath('src/entry/dialog-login.html'),         dialogPicker: resolvePath('src/entry/dialog-picker.html'),       },     },   }, })

这个配置的作用就是:

告诉 Vite:我要打包多个 HTML 页面入口。

最终你会得到类似这些产物:

dist/ ├─ taskpane-home.html ├─ taskpane-settings.html ├─ dialog-login.html ├─ dialog-picker.html ├─ assets/ │  ├─ xxx.js │  └─ xxx.css

四、每个 HTML 入口怎么写

src/entry/taskpane-home.html

<!doctype html> <html>   <head>     <meta charset="UTF-8" />     <meta name="viewport" content="width=device-width, initial-scale=1.0" />     <title>TaskPane Home</title>   </head>   <body>     <div id="app"></div>     <script type="module" src="/src/pages/taskpane-home/main.ts"></script>   </body> </html>

src/entry/taskpane-settings.html

<!doctype html> <html>   <head>     <meta charset="UTF-8" />     <meta name="viewport" content="width=device-width, initial-scale=1.0" />     <title>TaskPane Settings</title>   </head>   <body>     <div id="app"></div>     <script type="module" src="/src/pages/taskpane-settings/main.ts"></script>   </body> </html>

src/entry/dialog-login.html

<!doctype html> <html>   <head>     <meta charset="UTF-8" />     <meta name="viewport" content="width=device-width, initial-scale=1.0" />     <title>Dialog Login</title>   </head>   <body>     <div id="app"></div>     <script type="module" src="/src/pages/dialog-login/main.ts"></script>   </body> </html>

src/entry/dialog-picker.html

<!doctype html> <html>   <head>     <meta charset="UTF-8" />     <meta name="viewport" content="width=device-width, initial-scale=1.0" />     <title>Dialog Picker</title>   </head>   <body>     <div id="app"></div>     <script type="module" src="/src/pages/dialog-picker/main.ts"></script>   </body> </html>

五、每个 Vue 页面如何挂载

src/pages/taskpane-home/main.ts

import { createApp } from 'vue' import App from './App.vue' import '@/shared/styles/reset.css' import '@/shared/styles/common.css' createApp(App).mount('#app')

src/pages/dialog-login/main.ts

import { createApp } from 'vue' import App from './App.vue' import '@/shared/styles/reset.css' import '@/shared/styles/common.css' createApp(App).mount('#app')

六、页面示例代码

src/pages/taskpane-home/App.vue

<template>   <div>     <h1>主任务窗格</h1>     <p>这里适合放主功能入口、文档分析、批量操作等。</p>     <div>       <button @click="openLoginDialog">打开登录弹窗</button>       <button @click="openPickerDialog">打开选择器弹窗</button>       <button @click="goSettingsPane">打开设置窗格</button>     </div>     <div>       <p>当前登录状态:{{ token ? '已登录' : '未登录' }}</p>       <p>当前选择值:{{ selectedValue || '暂无' }}</p>     </div>   </div> </template> <script setup> import { ref, onMounted } from 'vue' import { getStorage, setStorage } from '@/shared/utils/storage' import { showDialog, createTaskPane } from '@/shared/utils/wps' const token = ref('') const selectedValue = ref('') function syncState() {   token.value = getStorage('wps_plugin_token') || ''   selectedValue.value = getStorage('wps_plugin_selected_value') || '' } function openLoginDialog() {   showDialog('/dialog-login.html') } function openPickerDialog() {   showDialog('/dialog-picker.html?mode=pick') } function goSettingsPane() {   createTaskPane('/taskpane-settings.html') } onMounted(() => {   syncState()   window.addEventListener('storage', syncState)   // 轮询方式兜底,适合某些宿主环境 storage 事件不稳定的情况   setInterval(syncState, 1000) }) </script> <style scoped> .page {   padding: 16px; } .btn-group {   display: flex;   gap: 12px;   flex-wrap: wrap;   margin: 16px 0; } .card {   padding: 12px;   border: 1px solid #ddd;   border-radius: 8px; } button {   padding: 8px 14px;   cursor: pointer; } </style>

src/pages/dialog-login/App.vue

<template>   <div>     <h2>登录弹窗</h2>     <div>       <label>账号:</label>       <input v-model="username" placeholder="请输入账号" />     </div>     <div>       <label>密码:</label>       <input v-model="password" type="password" placeholder="请输入密码" />     </div>     <div>       <button @click="handleLogin">登录</button>       <button @click="handleClose">关闭</button>     </div>   </div> </template> <script setup> import { ref } from 'vue' import { setStorage } from '@/shared/utils/storage' const username = ref('') const password = ref('') function handleLogin() {   // 这里你后续可以替换为真实接口   const fakeToken = `token_${Date.now()}`   setStorage('wps_plugin_token', fakeToken)   setStorage('wps_plugin_user', username.value || 'anonymous')   alert('登录成功')   handleClose() } function handleClose() {   // 真实场景里你可以替换成 WPS dialog 关闭 API   window.close() } </script> <style scoped> .page {   padding: 20px; } .form-item {   margin-bottom: 12px; } label {   display: inline-block;   width: 60px; } input {   padding: 6px 8px;   width: 220px; } .btn-group {   margin-top: 16px;   display: flex;   gap: 12px; } button {   padding: 8px 14px;   cursor: pointer; } </style>

src/pages/dialog-picker/App.vue

<template>   <div>     <h2>选择器弹窗</h2>     <ul>       <li v-for="item in options" :key="item.value">         <button @click="selectItem(item.value)">           {{ item.label }}         </button>       </li>     </ul>     <div>       <button @click="handleClose">关闭</button>     </div>   </div> </template> <script setup> import { setStorage } from '@/shared/utils/storage' const options = [   { label: '模板 A', value: 'A' },   { label: '模板 B', value: 'B' },   { label: '模板 C', value: 'C' }, ] function selectItem(value: string) {   setStorage('wps_plugin_selected_value', value)   alert(`已选择:${value}`)   handleClose() } function handleClose() {   window.close() } </script> <style scoped> .page {   padding: 20px; } ul {   padding-left: 0;   list-style: none; } li {   margin-bottom: 12px; } button {   padding: 8px 14px;   cursor: pointer; } .btn-group {   margin-top: 16px; } </style>

src/pages/taskpane-settings/App.vue

<template>   <div>     <h1>设置页</h1>     <div>       <label>接口地址:</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="save">保存</button>     </div>   </div> </template> <script setup> import { ref, onMounted } from 'vue' import { getStorage, setStorage } from '@/shared/utils/storage' const apiBaseUrl = ref('') const theme = ref('light') onMounted(() => {   apiBaseUrl.value = getStorage('wps_plugin_api_base_url') || ''   theme.value = getStorage('wps_plugin_theme') || 'light' }) function save() {   setStorage('wps_plugin_api_base_url', apiBaseUrl.value)   setStorage('wps_plugin_theme', theme.value)   alert('保存成功') } </script> <style scoped> .page {   padding: 16px; } .form-item {   margin-bottom: 12px; } label {   display: inline-block;   width: 90px; } input, select {   padding: 6px 8px;   min-width: 220px; } .btn-group {   margin-top: 16px; } button {   padding: 8px 14px;   cursor: pointer; } </style>

七、公共工具层怎么写

1. 本地存储工具

src/shared/utils/storage.ts

export function setStorage(key: string, value: any) {   localStorage.setItem(key, JSON.stringify(value)) } export function getStorage<T = any>(key: string): T | null {   const raw = localStorage.getItem(key)   if (!raw) return null   try {     return JSON.parse(raw) as T   } catch (error) {     return raw as T   } } export function removeStorage(key: string) {   localStorage.removeItem(key) }

2. WPS 封装工具

你后续真正接 WPSJS 时,可以集中收口在这里。

src/shared/utils/wps.ts

declare global {   interface Window {     wps?: any     WpsApplication?: any   } } export function getWpsApplication() {   return window.wps || window.WpsApplication || null } export function createTaskPane(url: string) {   const app = getWpsApplication()   if (!app) {     console.warn('未检测到 WPS Application,当前仅做浏览器调试')     window.open(url, '_blank')     return   }   try {     // 这里按你实际使用的 WPSJS API 调整     app.CreateTaskPane(url)   } catch (error) {     console.error('创建 TaskPane 失败:', error)   } } export function showDialog(url: string) {   const app = getWpsApplication()   if (!app) {     console.warn('未检测到 WPS Application,当前仅做浏览器调试')     window.open(url, '_blank', 'width=600,height=500')     return   }   try {     // 这里按你实际使用的 WPSJS API 调整     app.ShowDialog(url)   } catch (error) {     console.error('打开 Dialog 失败:', error)   } }

这里我特意写成了“浏览器调试也能跑”的形式。
这样你在普通浏览器里先开发页面,不必每次都卡在宿主环境里。


八、页面之间怎么通信

这是重点。

因为多个 taskpane / dialog 不是一个 Vue 应用,所以不要想着共用一个 Pinia 实例。

推荐你这样分层。


1. 轻量级通信:用 localStorage

适合:

  • 登录态

  • 当前选择值

  • 配置项

  • 简单标记位

优点是简单。

缺点是:

  • 不是强实时

  • 宿主环境下 storage 事件不一定稳定

所以我建议加一个轻轮询兜底。


2. 页面打开时用 URL 参数传值

比如:

showDialog('/dialog-picker.html?mode=pick&docId=123')

dialog 页面再去解析参数。

你可以写个工具函数。

src/shared/utils/url.ts

export function getQuery(name: string): string {   const params = new URLSearchParams(window.location.search)   return params.get(name) || '' }

3. 复杂数据统一走接口

一旦业务变复杂,比如:

  • 模板列表

  • 文档分析结果

  • 用户权限

  • 企业配置

这些最好统一走后端接口,不要靠页面之间硬传。


九、是否要用 Vue Router

我的建议是:

1. 单个 taskpane 内页面很多

可以用 Vue Router。

例如:

  • 首页

  • 历史记录

  • 设置

  • 帮助中心

这种都属于“同一个 taskpane 内部切换”。

那就适合上 Router。


2. dialog 很简单

一般没必要上 Router。

例如:

  • 登录弹窗

  • 选择器弹窗

  • 确认弹窗

通常一个 App.vue 就够了。


3. 不要试图让一个 Router 管全部 taskpane/dialog

这个思路不对。

Router 的边界应该是:

当前这个页面入口内部

而不是整个插件所有窗格一起。


十、是否要用 Pinia

可以用,但要理解边界。

1. 每个页面内部都可以用 Pinia

比如 taskpane-home 里有很多组件,它们共享状态,这很好。

2. 但不同 taskpane / dialog 之间不要指望共享同一个 Pinia 实例

因为它们不是同一个运行时。

所以更准确的说法是:

  • Pinia 适合做“页面内状态”

  • localStorage / 接口 适合做“页面间状态”


十一、package.json 建议

{   "name": "wps-vue-addin",   "version": "1.0.0",   "private": true,   "scripts": {     "dev": "vite",     "build": "vite build",     "preview": "vite preview"   },   "dependencies": {     "vue": "^3.5.0",     "pinia": "^2.1.7",     "axios": "^1.7.2"   },   "devDependencies": {     "@vitejs/plugin-vue": "^5.1.0",     "typescript": "^5.5.0",     "vite": "^5.4.0"   } }

十二、开发顺序建议

你别一上来就搞太复杂,按这个顺序最稳。

第一步:先把多入口跑通

先确认这四个页面都能独立启动:

  • taskpane-home.html

  • taskpane-settings.html

  • dialog-login.html

  • dialog-picker.html


第二步:先不接 WPS,浏览器里开发页面

也就是先用:

window.open(...)

模拟 dialog / taskpane 打开。

把 Vue 页面先做顺。


第三步:抽公共工具层

把这些收进 shared/

  • 存储

  • API

  • URL 参数

  • WPS 封装

  • 通用组件


第四步:再接 WPSJS API

也就是把:

  • window.open

  • alert

  • window.close

这些逐步替换成宿主环境真实 API。


第五步:最后再接业务接口

比如:

  • 登录接口

  • 模板列表

  • 文档分析

  • 用户配置


十三、最适合你的工程原则

我帮你再压缩成一句话:

原则一

整个插件按“多页面入口”设计。

原则二

每个入口内部按 Vue SPA 设计。

原则三

页面间共享数据,不共享 Vue 内存状态。

原则四

WPS 相关 API 全部收口到 shared/utils/wps.ts

这四条你守住,后面基本不会乱。


十四、你现在最容易踩的坑

1. 想搞成一个超级总入口

短期看统一,后期很乱。

2. 试图跨 taskpane/dialog 共用一个 store

这是伪需求,最后一定绕。

3. WPS API 到处散落

后面改接口和调试会非常痛苦。

4. 页面通信全靠 window 对象互调

这很脆,宿主环境更不稳。


十五、如果你要我给一个最终推荐方案

我给你的定案就是:

推荐方案

taskpane

  • 一个任务窗格一个入口页面

  • 内部复杂再上 Vue Router

dialog

  • 一个弹窗一个入口页面

  • 一般不用 Router

状态

  • 页面内用 Pinia

  • 页面间用 localStorage + URL 参数 + 后端接口

工程

  • Vite 多入口

  • Vue 3

  • TypeScript

  • WPS API 统一封装

这就是最稳的企业级写法。


十六、我再补你一个“更进一步”的升级版方向

后续你项目大了,可以继续升级成这样:

src/ ├─ core/ │  ├─ bootstrap/ │  ├─ router/ │  ├─ store/ │  └─ plugins/ ├─ modules/ │  ├─ auth/ │  ├─ document/ │  ├─ template/ │  └─ settings/ ├─ pages/ ├─ shared/

也就是把代码再按业务模块拆分。

但你现阶段,先用我上面那版就够了,实用而且不容易乱。