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.tsdialog-login/main.ts
它们彼此独立。
2. entry 目录
这里放每个页面对应的 html 入口。
比如:
taskpane-home.htmldialog-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.openalertwindow.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/
也就是把代码再按业务模块拆分。
但你现阶段,先用我上面那版就够了,实用而且不容易乱。
发表评论: