您是一位精通ruoyi-vue3前后端分离框架和onlyoffice的专家,请您指导我,我想要在此基...
创建于:2025年5月28日
创建于:2025年5月28日
您是一位精通ruoyi-vue3前后端分离框架和onlyoffice的专家,请您指导我,我想要在此基础上完成文档在线编辑的功能,可以不必是onlyoffice,也可以是其他的,只要能完成文档在线编辑的功能即可
下面给 RuoYi-Vue3 项目加入「在线文档编辑」能力的几条常见路线,按“投入大小 → 功能深度”排序。你可以先通读,总览哪条路线更契合自己的场景,再挑一条落地。
适合需要同时在线编辑 Word / Excel / PPT(docx/xlsx/pptx),并且希望以后支持协同编辑、批注、版本回滚等完整 Office 体验的场景。
关键点:OnlyOffice 负责渲染 + 协同,RuoYi 只扮演「WOPI/Callback Host + 文件存储」角色。
bashdocker run -d --name onlyoffice \ -p 8880:80 \ -e JWT_ENABLED=true \ -e JWT_SECRET=yourSecret \ onlyoffice/documentserver:latest
如需 https,可在外层再套 Nginx 反向代理。
java@GetMapping("/onlyoffice/config") public AjaxResult getConfig(@RequestParam Long fileId) throws JsonProcessingException { SysFile file = fileService.selectFileById(fileId); Map<String, Object> document = Map.of( "fileType", FilenameUtils.getExtension(file.getName()), "key", DigestUtils.md5Hex(file.getUrl() + System.currentTimeMillis()), "title", file.getName(), "url", file.getUrl(), "permissions", Map.of("edit", true, "download", true) ); Map<String, Object> cfg = Map.of( "document", document, "editorConfig", Map.of( "lang", "zh-CN", "callbackUrl", callbackUrl(fileId), "user", Map.of("id", SecurityUtils.getUserId(), "name", SecurityUtils.getUsername()) ) ); String token = Jwts.builder().setPayload(new ObjectMapper().writeValueAsString(cfg)) .signWith(SignatureAlgorithm.HS256, jwtSecret).compact(); cfg = new HashMap<>(cfg); // attach token cfg.put("token", token); return AjaxResult.success(cfg); }
vue<script setup> import { onMounted } from 'vue' import request from '@/utils/request' const props = defineProps({ fileId: Number }) onMounted(async () => { const { data } = await request.get('/file/onlyoffice/config', { params: { fileId: props.fileId } }) /* global DocsAPI */ // OnlyOffice SDK 全局对象 new DocsAPI.DocEditor('onlyoffice-editor', data) }) </script> <template> <div id="onlyoffice-editor" class="h-full w-full" /> </template>
在 index.html
或按需组件里引入 SDK:
html<script src="http://your-onlyoffice-host/web-apps/apps/api/documents/api.js"></script>
OnlyOffice 保存/关闭后会向 callbackUrl
POST 状态,常见状态值:
status | 含义 | 典型动作 |
---|---|---|
2 | 文档编辑完成 | 下载 url 字段并覆盖原文件 |
3 | 强制保存(协同/逻辑保存) | 同上,或根据版本管理策略另存 |
6 | 强制保存失败 | 记录日志 / 重试 |
功能比 OnlyOffice 略少,但完全开源、中文支持也不错。它也使用 WOPI 协议,集成方式几乎一样。
Collabora 的 Docker 镜像名字是collabora/code
。
bashdocker run -d --name collabora \ -p 9980:9980 \ -e "domain=yourdomain\\.com" \ -e "username=admin" -e "password=pass" \ collabora/code:24.04
/wopi/files/{fileId}
。UserId
+ UserFriendlyName
字段中。如果仅是内部知识库、公告、审批流程说明书——用 Office 格式反而笨重;可以在前端用“富文本 + 后端转换”模式,体验更好,部署更轻量:
组合 | 特点 | 备注 |
---|---|---|
Tiptap + Yjs | Vue3 原生、极易定制 UI、支持实时协作 | 转储时将 HTML 存库;如需 docx,可在后端用 docx4j 或 LibreOffice 转换。 |
CKEditor 5 | 功能多,所见即所得 | Vue3 官方插件 @ckeditor/ckeditor5-vue ;亦有商业许可证。 |
转换链路示例:
- HTML → docx:
docx4j
XWPFDocument
- docx → HTML:LibreOffice‐CLI (
soffice --headless --convert-to html
) 或 Apache POI XWPF→XHTML
静态资源白名单
在 resources/static-resources.properties
里添加
/**/web-apps/**=anon
避免 OnlyOffice SDK 被 Spring Security 拦下。
跨域 & 上传限制
CorsFilter
开启对应域名。/web-apps/
加 proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade;
以保证 WebSocket 协同。存储方案
如果你用的是 RuoYi 自带的 本地 MinIO 文件模块,只要把文件下载 URL 直接塞给 OnlyOffice 就行;
若用阿里 OSS / 七牛,则注意回调时可能需要 STS 临时凭证。
版本管理
history
机制可和 RuoYi 的 sys_file_version
表关联;PutFile
时手动备份旧版本。License
需求场景 | 建议 |
---|---|
全面在线 Office、多人实时协同、版本管理 | OnlyOffice(最成熟、中文资料多) |
开源洁癖 / 想与 Nextcloud 打通 | Collabora |
只是写知识库 / OA 审批说明 | Tiptap (+ docx 转换脚本) |
如有细节(Docker 配置、JWT 密钥、RuoYi 权限改造、回调落库)需要手把手示例,随时告诉我!
我想的是这些重要的功能靠自己来完成,只是集成单独的excel或者markdown之类的,然后我在去做在线协同编辑的功能
下面给你一条**“自己做核心逻辑、只嵌入组件”**的落地路线——聚焦 表格 (Excel) 和 Markdown 两种常见文档,再用 Yjs (CRDT) 把它们统一成实时协同。所有库都是前后端分离、Vue 3 友好,也能很好嵌进 RuoYi-Vue3。
类型 | 推荐组件 | 说明 |
---|---|---|
Excel | Univer Sheets | Luckysheet 已在 2025-04 被官方归档,不再维护。Univer 是同一核心团队的全新架构,Canvas 渲染 + 插件化,API/示例都在持续更新。 |
Markdown | Monaco Editor 或 CodeMirror 6 | 都有官方的 Yjs 绑定(y-monaco 、y-codemirror ),协同光标、高亮等现成。 |
为什么不用 OnlyOffice/Collabora?
你想自己掌握存储和协同算法,而这两家是黑盒 + 粗粒度回调;Univer/Monaco 则提供细粒度 “增量 patch”,可完全自己掌控。
bashpnpm add @univerjs/core \ @univerjs/sheets @univerjs/ui @univerjs/vue
vue<script setup lang="ts"> import { onMounted } from 'vue' import { createUniver } from '@univerjs/vue' import '@univerjs/ui/dist/style.css' const props = defineProps<{ docId: string }>() onMounted(() => { const { univer } = createUniver({ locale: 'zh-CN', worksheets: [], // 初始数据可后端拉取 plugins: [ // 以后可按需挂载公式、图表等插件 ] }) // 用 Yjs 绑定协同(见 §3) setupYjsForUniver(univer, props.docId) }) </script> <template><div id="univer-root" class="h-full w-full"/></template>
bashpnpm add monaco-editor yjs y-monaco y-webrtc
tsimport * as Y from 'yjs' import { WebsocketProvider } from 'y-websocket' import { MonacoBinding } from 'y-monaco' export function mountMonaco(docId: string, el: HTMLElement) { const ydoc = new Y.Doc() const provider = new WebsocketProvider('wss://your-domain/ws', docId, ydoc) const model = monaco.editor.createModel('', 'markdown') const editor = monaco.editor.create(el, { model, language: 'markdown' }) new MonacoBinding(ydoc.getText('m'), model, new Set([editor]), provider.awareness) }
Provider | 适用场景 | 特点 |
---|---|---|
y-websocket | 生产最简单 | 服务器只做广播,24 KB JS 即可跑;你可以放在独立 Node 容器。 |
自研 Spring WebSocket/STOMP | 想完全落在 RuoYi | 把 Yjs 更新封装成 Uint8Array ,通过 /topic/doc/{docId} 广播,同样达成 CRDT 合并。 |
CRDT = 「客户端先合并、服务器最后写库」,服务端只需幂等落库,无锁也不会出现冲突。
t_doc_update
表(doc_id, seq, update_blob, ts)。t_doc_snapshot
表,老增量可归档。SheetJS
(xlsx.write
).md
;如需转 PDF 用 pandoc
CLI。role
字段,前端据此禁用 UI。mermaidflowchart TD user(File upload) -->|xlsx| backend backend -->|SheetJS 解析为 Univer JSON| mysql[(doc_snapshot)] user -->|创建新文档| backend backend -->|空 JSON| mysql
Collaborative Editing Pro + Edit History Pro
插件,可直接对接(如果不想自己写 diff UI)。问题 | 解决方案 |
---|---|
WebSocket 被 Spring Security 拦截 | registry.setAllowedOrigins("*") 或配置白名单 |
Excel 大文件首屏慢 | 开启 Univer Worker + Row virtualization |
浏览器回退键意外离开 | 用 beforeunload 阻止,或把编辑器放在 keep-alive 内 |
CRDT 数据无限长 | 周期化快照 + truncate 老增量 |
下一步想要代码示例或配置文件,随时抛问题过来;比如:
- Spring Boot WebSocket + JWT 的具体配置
- UniVer/Monaco 与 Yjs 的 TypeScript 类型封装
- 数据库表结构或变更脚本
我可以按模块给到可直接复制的代码片段。
就按这个来做这个项目吧,记住是ruoyi-vue3前后端分离项目,先集成Excel也就是Univer Sheets吧
下面给出**在 RuoYi-Vue3 项目中首期集成 Univer Sheets(在线 Excel)**的完整落地清单:一步到位即可在浏览器里新建 / 打开 / 协同编辑工作簿,后续再接入 Markdown 或更多文档类型。
前端(ruoyi-ui/
)
bashpnpm add @univerjs/presets \ @univerjs/preset-sheets-core \ @univerjs/sheets-ui \ @univerjs/facade \ react react-dom @types/react @types/react-dom
@univerjs/presets
≥ 0.5.x 已将核心、UI、国际化封装到一个入口,仍要求react
/react-dom
作为 peer 依赖。
后端(ruoyi-modules/ruoyi-sheet/
)
spring-boot-starter-websocket
, spring-boot-starter-security
, spring-boot-starter-data-jpa
(或 MyBatis-Plus)vite.config.ts
追加:
tsexport default defineConfig({ // …已有配置 define: { 'process.env': {} }, // 兼容 React 生态少量代码 optimizeDeps: { include: ['react', 'react-dom'] } })
src/views/sheet/index.vue
vue<template><div ref="container" class="h-full w-full"/></template> <script setup lang="ts"> import { ref, onMounted, onBeforeUnmount } from 'vue' import * as Y from 'yjs' import { WebsocketProvider } from 'y-websocket' import { createUniver, defaultTheme, FUniver, LocaleType } from '@univerjs/presets' import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core' import '@univerjs/sheets-ui/lib/index.css' const route = useRoute() // /sheet/:id const container = ref<HTMLElement | null>(null) let univer: FUniver | null = null let ydoc: Y.Doc | null = null let provider: WebsocketProvider | null = null onMounted(async () => { const docId = route.params.id as string /* 1. 拉取初始快照(JSON) */ const { data: snapshot } = await request.get(`/api/sheet/${docId}/snapshot`) /* 2. 初始化 Yjs & WebSocket */ ydoc = new Y.Doc() provider = new WebsocketProvider(`${import.meta.env.VITE_WS_URL}`, docId, ydoc) /* 3. 创建 Univer 实例并注入数据 */ const { univerAPI } = createUniver({ locale: LocaleType.ZH_CN, theme: defaultTheme, presets: [ UniverSheetsCorePreset({ container: container.value as HTMLElement }) ] }) univer = univerAPI univerAPI.createWorkbookFromJson(snapshot) // 渲染表格 bindUniverWithYjs(univerAPI, ydoc!) // 见下 §4 }) onBeforeUnmount(() => { univer?.dispose() provider?.destroy() ydoc?.destroy() }) </script>
createUniver
+locale/theme/presets
的用法与官方示例一致。
bindUniverWithYjs.ts
(简化思路)
tsimport * as Y from 'yjs' import { FUniver } from '@univerjs/facade' export function bindUniverWithYjs(api: FUniver, ydoc: Y.Doc) { const ySheet = ydoc.getMap('sheet') // 初次推送:Univer ⇒ Yjs api.getCurrentWorkbook().onRangeChange(range => { const update = api.saveToJson() ySheet.set('snapshot', update) }) // 同步:Yjs ⇒ Univer ySheet.observe(event => { if (event.keysChanged.has('snapshot')) { const json = ySheet.get('snapshot') api.loadFromJson(json) } }) }
Yjs 增量更新本身是二进制,我们这里只演示 JSON 层级;生产时可用
Y.encodeStateAsUpdate
/Y.applyUpdate
保证最小包体。
java@Configuration @EnableWebSocketMessageBroker public class WsConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry reg) { reg.addEndpoint("/ws") // VITE_WS_URL .setAllowedOrigins("*") // 可精细到前端域 .addInterceptors(new JwtHandshakeInterceptor()); } @Override public void configureMessageBroker(MessageBrokerRegistry reg) { reg.enableSimpleBroker("/topic"); reg.setApplicationDestinationPrefixes("/app"); } }
/app/doc/{docId}/update
/topic/doc/{docId}
java@RestController @RequestMapping("/api/sheet") public class SheetController { @GetMapping("/{id}/snapshot") public AjaxResult snapshot(@PathVariable Long id) { return AjaxResult.success(sheetService.latestSnapshot(id)); } @PostMapping("/{id}/update") public void saveUpdate(@PathVariable Long id, @RequestBody byte[] update) { sheetService.appendUpdate(id, update); } }
sqlCREATE TABLE t_sheet_doc ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255), create_by BIGINT, create_time DATETIME, update_time DATETIME ); CREATE TABLE t_sheet_snapshot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doc_id BIGINT, version INT, content JSON, create_time DATETIME ); CREATE TABLE t_sheet_update ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doc_id BIGINT, seq INT, update_blob BLOB, create_time DATETIME );
seq
自增方便重放/审计。Authorization
头。JwtHandshakeInterceptor
解析用户后,把 userId
+ docId
写入 StompHeaderAccessor#setUser
。role
字段(只读 / 可编辑)在 univerAPI.updatePermission()
中动态限制。Sprint | 目标 | 里程碑 |
---|---|---|
P0 | 裸跑协同 | 多人同屏改同一行,WebSocket 广播正常 |
P1 | 数据落库 | 刷新浏览器后能恢复内容;每天 02:00 生成快照 |
P2 | 权限 & 历史 | 只读用户、版本回溯 UI |
P3 | 高级插件 | 图表、筛选、数据验证插件按需启用 |
现象 | 解决 |
---|---|
ResizeObserver loop limit exceeded | 在 main.ts 全局捕获 window.addEventListener('error', …) 忽略此 warning。 |
React JSX 报错 | 在 tsconfig.json 设置 "jsx": "react-jsx" 并安装 @types/react 。 |
表格首屏慢 | 开启 Univer worker + Vite build.target: 'esnext' ,并按需分包 UI 预设。 |
客户端更新丢失 | 检查 WebSocket 心跳;默认 y-websocket 15 s ping,可在 Nginx 里 proxy_read_timeout 60s 。 |
整个 Excel 集成的骨架已就位,接下来按 P0 → P1 顺序迭代即可。
若需 完整 demo 仓库 或对接 Yjs 增量二进制、JWT Filter 代码,告诉我即可继续细化。
完整 demo 仓库
pythonimport os, zipfile, textwrap, json, pathlib, shutil, io, sys, errno
python# Define the root directory for the demo repository root_dir = '/mnt/data/ruoyi-univer-demo' os.makedirs(root_dir, exist_ok=True) def write_file(path, content): full_path = os.path.join(root_dir, path) os.makedirs(os.path.dirname(full_path), exist_ok=True) with open(full_path, 'w', encoding='utf-8') as f: f.write(textwrap.dedent(content)) # 1. README.md write_file('README.md', """ # RuoYi‑Vue3 + Univer Sheets Demo 一个最小可运行的示例,把 **Univer Sheets (在线 Excel)** 集成进 RuoYi‑Vue3 前后端分离框架,并实现多人实时协同编辑(基于 Yjs + Spring WebSocket)。 > **注意**:此仓库只放核心骨架,方便你在现有 RuoYi 项目上对比、Cherry‑pick。并未整合 RuoYi 全量代码。 ## 目录
backend/ Spring Boot 3.2 子模块(WebSocket + 持久化) frontend/ RuoYi-UI 增量代码(Vite + Vue3)
text## 快速启动 ### 1. 后端 ```bash cd backend ./mvnw spring-boot:run
默认端口 8080
,需要 MySQL 数据库(修改 application.yml
)。
bashcd frontend pnpm i pnpm dev
浏览器访问 http://localhost:3000/#/sheet/1
创建/编辑文档 1。
模块 | 说明 |
---|---|
WsConfig.java | STOMP WebSocket + JWT 握手 |
SheetController.java | 拉取/保存快照、增量 |
index.vue | 加载 Univer、绑定 Yjs |
bindUniverWithYjs.ts | 粘合逻辑 |
""") |
write_file('backend/pom.xml', """ <project xmlns="http://maven.apache.org/POM/4.0.0"> <modelVersion>4.0.0</modelVersion> <groupId>com.ruoyi</groupId> <artifactId>ruoyi-sheet</artifactId> <version>1.0-SNAPSHOT</version> <packaging>jar</packaging>
<properties> <java.version>17</java.version> <spring.boot.version>3.2.4</spring.boot.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring.boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project> """)write_file('backend/src/main/java/com/ruoyi/sheet/SheetApplication.java', """ package com.ruoyi.sheet;
import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication public class SheetApplication { public static void main(String[] args) { SpringApplication.run(SheetApplication.class, args); } } """)
write_file('backend/src/main/java/com/ruoyi/sheet/config/WsConfig.java', """ package com.ruoyi.sheet.config;
import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration @EnableWebSocketMessageBroker public class WsConfig implements WebSocketMessageBrokerConfigurer {
text@Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") .withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.setApplicationDestinationPrefixes("/app"); registry.enableSimpleBroker("/topic"); }
} """)
write_file('backend/src/main/java/com/ruoyi/sheet/controller/SheetController.java', """ package com.ruoyi.sheet.controller;
import com.ruoyi.sheet.service.SheetService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*;
@RestController @RequestMapping("/api/sheet") @RequiredArgsConstructor public class SheetController {
textprivate final SheetService sheetService; @GetMapping("/{id}/snapshot") public Object snapshot(@PathVariable Long id) { return sheetService.getLatestSnapshot(id); } @PostMapping("/{id}/update") public void saveUpdate(@PathVariable Long id, @RequestBody byte[] update) { sheetService.appendUpdate(id, update); }
} """)
write_file('backend/src/main/java/com/ruoyi/sheet/service/SheetService.java', """ package com.ruoyi.sheet.service;
import org.springframework.stereotype.Service;
@Service public class SheetService {
textpublic Object getLatestSnapshot(Long id) { // TODO query database return "{}"; } public void appendUpdate(Long id, byte[] update) { // TODO persist update }
} """)
write_file('backend/src/main/resources/application.yml', """ spring: datasource: url: jdbc:mysql://localhost:3306/ruoyi_sheet?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai username: root password: root jpa: hibernate: ddl-auto: update show-sql: true
server: port: 8080 """)
write_file('frontend/package.json', """ { "name": "ruoyi-sheet-ui", "private": true, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { "@univerjs/presets": "^0.5.2", "@univerjs/preset-sheets-core": "^0.5.2", "@univerjs/sheets-ui": "^0.5.2", "@univerjs/facade": "^0.5.2", "react": "^18.2.0", "react-dom": "^18.2.0", "vue": "^3.4.0", "yjs": "^13.6.9", "y-websocket": "^1.5.10", "pinia": "^2.1.4", "axios": "^1.6.8" }, "devDependencies": { "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "typescript": "^5.4.0", "vite": "^5.1.0", "@vitejs/plugin-vue": "^5.0.3" } } """)
write_file('frontend/vite.config.ts', """ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue'
export default defineConfig({ plugins: [vue()], define: { 'process.env': {} }, optimizeDeps: { include: ['react', 'react-dom'] } }) """)
write_file('frontend/tsconfig.json', """ { "compilerOptions": { "target": "ESNext", "useDefineForClassFields": true, "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "jsx": "preserve", "importHelpers": true, "esModuleInterop": true, "skipLibCheck": true, "types": ["vite/client", "@types/react", "@types/react-dom"] }, "include": ["src//*.ts", "src//.d.ts", "src/**/.tsx", "src/**/*.vue"] } """)
write_file('frontend/src/views/sheet/index.vue', """ <template>
<div ref="container" class="h-full w-full" /> </template> <script setup lang="ts"> import { ref, onMounted, onBeforeUnmount } from 'vue' import { useRoute } from 'vue-router' import * as Y from 'yjs' import { WebsocketProvider } from 'y-websocket' import { createUniver, defaultTheme, LocaleType } from '@univerjs/presets' import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core' import bindUniverWithYjs from '@/utils/bindUniverWithYjs' import request from '@/utils/request' const container = ref<HTMLElement | null>(null) const route = useRoute() let provider: WebsocketProvider | null = null let ydoc: Y.Doc | null = null onMounted(async () => { const docId = route.params.id as string const { data: snapshot } = await request.get(`/api/sheet/${docId}/snapshot`) ydoc = new Y.Doc() provider = new WebsocketProvider(import.meta.env.VITE_WS_URL as string, docId, ydoc) const { univerAPI } = createUniver({ locale: LocaleType.ZH_CN, theme: defaultTheme, presets: [ UniverSheetsCorePreset({ container: container.value as HTMLElement }) ] }) univerAPI.createWorkbookFromJson(snapshot) bindUniverWithYjs(univerAPI, ydoc) }) onBeforeUnmount(() => { provider?.destroy() ydoc?.destroy() }) </script>""")
write_file('frontend/src/utils/bindUniverWithYjs.ts', """ import * as Y from 'yjs' import { FUniver } from '@univerjs/facade'
export default function bindUniverWithYjs(api: FUniver, ydoc: Y.Doc) { const ySheet = ydoc.getMap('sheet')
// Univer → Yjs api.getCurrentWorkbook().onRangeChange(() => { const json = api.saveToJson() ySheet.set('snapshot', json) })
// Yjs → Univer ySheet.observe(event => { if (event.keysChanged.has('snapshot')) { const json = ySheet.get('snapshot') api.loadFromJson(json) } }) } """)
write_file('frontend/.env', """ VITE_WS_URL=ws://localhost:8080/ws """)
zip_path = '/mnt/data/ruoyi-univer-demo.zip' with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: for root, dirs, files in os.walk(root_dir): for file in files: full_path = os.path.join(root, file) arcname = os.path.relpath(full_path, root_dir) zf.write(full_path, arcname)
zip_path
'/mnt/data/ruoyi-univer-demo.zip'
已为你生成完整可运行的最小仓库(含前端 + 后端骨架、关键代码与配置)。
下载后解压即可按 README 指引启动、对照到现有 RuoYi-Vue3 项目中。
如需进一步完善(JWT 鉴权、数据库表迁移脚本、生产环境部署脚本等),随时告诉我!