From 7b139d404ede358bd6b25777e3efad85c838666e Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 5 Oct 2025 01:18:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=8F=E5=90=8C=E7=BC=96=E8=BE=91=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 3 +- .env.production | 3 +- .env.staging | 3 +- .env.test | 3 +- docs/SYNC_COLLABORATION.md | 678 ++++++++++++++++++ package-lock.json | 307 +++++++- package.json | 5 +- src/admin/user/components/Name.vue | 3 +- src/admin/user/list.vue | 11 +- src/env.d.ts | 1 + src/oj/problem/components/Editor.vue | 82 ++- src/oj/problem/components/Form.vue | 194 +++-- .../problem/components/ProblemSubmission.vue | 35 +- src/shared/components/CodeEditor.vue | 122 +++- src/shared/components/IconButton.vue | 10 +- src/shared/components/TextCopy.vue | 29 + src/shared/composables/sync.ts | 397 ++++++++++ src/utils/types.ts | 3 +- 18 files changed, 1728 insertions(+), 161 deletions(-) create mode 100644 docs/SYNC_COLLABORATION.md create mode 100644 src/shared/components/TextCopy.vue create mode 100644 src/shared/composables/sync.ts diff --git a/.env b/.env index 271f729..8dea88a 100644 --- a/.env +++ b/.env @@ -2,4 +2,5 @@ PUBLIC_ENV=dev PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=2e801f7d6efdcc99 PUBLIC_OJ_URL=http://localhost:8000 PUBLIC_CODE_URL=http://localhost:3000 -PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc \ No newline at end of file +PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc +PUBLIC_SIGNALING_URL=wss://ojsignaling.xuyue.cc \ No newline at end of file diff --git a/.env.production b/.env.production index 5cb41f8..0a89ca7 100644 --- a/.env.production +++ b/.env.production @@ -2,4 +2,5 @@ PUBLIC_ENV=xuyue.cc PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=2e801f7d6efdcc99 PUBLIC_OJ_URL=https://oj.xuyue.cc PUBLIC_CODE_URL=https://code.xuyue.cc -PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc \ No newline at end of file +PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc +PUBLIC_SIGNALING_URL=wss://ojsignaling.xuyue.cc \ No newline at end of file diff --git a/.env.staging b/.env.staging index 5df112d..c2b8e21 100644 --- a/.env.staging +++ b/.env.staging @@ -3,4 +3,5 @@ PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13 PUBLIC_OJ_URL=http://10.13.114.114:81 PUBLIC_CODE_URL=http://10.13.114.114:82 PUBLIC_JUDGE0_URL=http://10.13.114.114:8082 -PUBLIC_ICONIFY_URL=http://10.13.114.114:8098 \ No newline at end of file +PUBLIC_ICONIFY_URL=http://10.13.114.114:8098 +PUBLIC_SIGNALING_URL=wss://ojsignaling.xuyue.cc \ No newline at end of file diff --git a/.env.test b/.env.test index 5a2a0f3..f38cc60 100644 --- a/.env.test +++ b/.env.test @@ -3,4 +3,5 @@ PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13 PUBLIC_OJ_URL=http://10.13.114.114:81 PUBLIC_CODE_URL=http://10.13.114.114:82 PUBLIC_JUDGE0_URL=http://10.13.114.114:8082 -PUBLIC_ICONIFY_URL=http://10.13.114.114:8098 \ No newline at end of file +PUBLIC_ICONIFY_URL=http://10.13.114.114:8098 +PUBLIC_SIGNALING_URL=wss://ojsignaling.xuyue.cc \ No newline at end of file diff --git a/docs/SYNC_COLLABORATION.md b/docs/SYNC_COLLABORATION.md new file mode 100644 index 0000000..5a237d9 --- /dev/null +++ b/docs/SYNC_COLLABORATION.md @@ -0,0 +1,678 @@ +# 协同编辑功能文档 + +## 📚 目录 + +- [概述](#概述) +- [技术栈](#技术栈) +- [核心概念](#核心概念) +- [架构设计](#架构设计) +- [详细实现](#详细实现) +- [工作流程](#工作流程) +- [API 文档](#api-文档) +- [使用示例](#使用示例) +- [故障排查](#故障排查) + +## 概述 + +协同编辑功能允许超级管理员和学生在同一个代码编辑器中实时协作,查看彼此的代码修改和光标位置。该功能基于 Yjs 和 WebRTC 实现,支持点对点连接。 + +### 核心特性 + +- ✅ 实时代码同步 +- ✅ 光标位置共享 +- ✅ 用户颜色标识(超管红色,学生蓝色) +- ✅ 动态扩展加载(按需加载 yjs 依赖) +- ✅ 权限控制(必须有一个超管) +- ✅ 房间人数限制(最多 2 人) +- ✅ 离线检测和自动清理 + +## 技术栈 + +| 技术 | 版本 | 用途 | +|------|------|------| +| Yjs | Latest | CRDT 文档同步 | +| y-webrtc | Latest | WebRTC 传输层 | +| y-codemirror.next | Latest | CodeMirror 6 集成 | +| CodeMirror 6 | Latest | 代码编辑器 | +| Vue 3 | Latest | 前端框架 | + +## 核心概念 + +### 1. Yjs (Y.js) + +Yjs 是一个 CRDT(Conflict-free Replicated Data Type)实现,提供: +- 无冲突的并发编辑 +- 操作的自动合并 +- 离线支持 + +### 2. WebRTC Provider + +通过 WebRTC 建立点对点连接: +- 信令服务器协调连接 +- P2P 数据传输 +- 自动重连机制 + +### 3. Awareness + +Awareness 协议用于共享用户状态: +- 用户信息(名称、角色) +- 光标位置 +- 选择范围 +- 用户颜色 + +### 4. Compartment + +CodeMirror 的扩展管理机制: +- 动态添加/移除扩展 +- 无需重新创建编辑器 +- 保持编辑器状态 + +## 架构设计 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 用户界面层 │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Form.vue │ │ Editor.vue │ │CodeEditor.vue│ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +└─────────┼──────────────────┼──────────────────┼──────────────┘ + │ │ │ + │ Props/Events│ │ + │ ▼ │ + │ ┌─────────────────┐ │ + │ │ Sync Control │ │ + │ │ (Editor.vue) │ │ + │ └────────┬────────┘ │ + │ │ │ + └──────────────────┼──────────────────┘ + │ + ▼ + ┌──────────────────────────────────────┐ + │ sync.ts (useCodeSync) │ + │ ┌────────────────────────────────┐ │ + │ │ 状态管理 │ │ + │ │ - ydoc, provider, ytext │ │ + │ │ - collabCompartment │ │ + │ │ - roomUserInfo │ │ + │ └────────────────────────────────┘ │ + │ ┌────────────────────────────────┐ │ + │ │ 核心功能 │ │ + │ │ - startSync() │ │ + │ │ - stopSync() │ │ + │ │ - 权限检查 │ │ + │ │ - 内容同步 │ │ + │ └────────────────────────────────┘ │ + └──────────────┬───────────────────────┘ + │ + ┌──────────────┴───────────────┐ + │ │ + ▼ ▼ + ┌───────────────┐ ┌─────────────────┐ + │ Yjs CRDT │◄──────────►│ WebRTC P2P │ + │ Document │ │ Connection │ + └───────────────┘ └─────────────────┘ + │ │ + │ │ + ▼ ▼ + ┌───────────────┐ ┌─────────────────┐ + │ CodeMirror │ │ Signaling │ + │ Extension │ │ Server │ + └───────────────┘ └─────────────────┘ +``` + +## 详细实现 + +### 1. 初始化流程 + +```typescript +// 1. 检查用户登录状态 +if (!userStore.isAuthed) { + return error +} + +// 2. 动态导入依赖(按需加载) +const [Y, { WebrtcProvider }, { yCollab }] = await Promise.all([ + import("yjs"), + import("y-webrtc"), + import("y-codemirror.next"), +]) + +// 3. 创建 Yjs 文档 +ydoc = new Y.Doc() +ytext = ydoc.getText("codemirror") + +// 4. 创建 WebRTC Provider +provider = new WebrtcProvider(roomName, ydoc, { + signaling: [SIGNALING_URL], + maxConns: 1, + filterBcConns: true, +}) + +// 5. 设置用户信息 +provider.awareness.setLocalStateField("user", { + name: userName, + color: userColor, + isSuperAdmin: isSuperAdmin, +}) + +// 6. 应用协同编辑扩展 +const collabExt = yCollab(ytext, provider.awareness) +editorView.dispatch({ + effects: collabCompartment.reconfigure(collabExt), +}) +``` + +### 2. 权限检查机制 + +#### 房间人数检查 +```typescript +if (roomUsers > MAX_ROOM_USERS) { + // 超过2人,断开连接 + stopSync() + return +} +``` + +#### 超管检查 +```typescript +const checkHasSuperAdmin = (awarenessStates) => { + if (userStore.isSuperAdmin) return true + return Array.from(awarenessStates.values()).some( + (state) => state.user?.isSuperAdmin + ) +} +``` + +#### 状态判断逻辑 +```typescript +if (roomUsers === 2 && !hasSuperAdmin) { + // 房间满员但没有超管 + status = "error" +} else if (roomUsers === 2 && hasSuperAdmin) { + // 房间满员且有超管,可以协作 + status = "active" +} else { + // 等待其他用户加入 + status = "waiting" +} +``` + +### 3. 内容同步策略 + +#### 第一个进入房间的用户 + +```typescript +// 1. 保存当前编辑器内容 +const savedContent = editorView.state.doc.toString() + +// 2. 清空编辑器 +editorView.dispatch({ + changes: { from: 0, to: doc.length, insert: "" } +}) + +// 3. 应用协同扩展 + +// 4. 等待同步完成或超时 +setTimeout(() => { + if (ytext.length === 0 && savedContent) { + // 房间为空,写入本地内容 + ytext.insert(0, savedContent) + } +}, INIT_SYNC_TIMEOUT) +``` + +#### 第二个进入房间的用户 + +```typescript +// 1. 保存当前编辑器内容(将被覆盖) +const savedContent = editorView.state.doc.toString() + +// 2. 清空编辑器 +editorView.dispatch({ + changes: { from: 0, to: doc.length, insert: "" } +}) + +// 3. 应用协同扩展 + +// 4. 监听首次同步完成 +provider.on("synced", (event) => { + if (ytext.length > 0) { + // yCollab 自动将远程内容同步到编辑器 + // 无需手动操作 + } +}) +``` + +### 4. 事件监听 + +#### 连接状态监听 +```typescript +provider.on("status", (event) => { + if (!event.connected) { + // 连接断开,通知用户 + message.warning("协同编辑连接已断开") + } +}) +``` + +#### 用户加入/离开监听 +```typescript +provider.on("peers", (event) => { + const roomUsers = event.webrtcPeers.length + 1 + // 检查房间人数和权限 + checkRoomPermissions(roomUsers) +}) +``` + +#### Awareness 变化监听 +```typescript +provider.awareness.on("change", (changes) => { + // 检查超管是否离开 + if (changes.removed?.length > 0) { + checkIfSuperAdminLeft(changes.removed) + } + + // 更新房间用户信息 + updateRoomUserInfo(awarenessStates) + + // 重新检查权限 + checkRoomPermissions(roomUsers) +}) +``` + +### 5. 清理机制 + +```typescript +function stopSync() { + // 1. 移除编辑器扩展 + currentEditorView?.dispatch({ + effects: collabCompartment.reconfigure([]) + }) + + // 2. 断开并销毁 Provider + provider?.disconnect() + provider?.destroy() + + // 3. 销毁 Yjs 文档 + ydoc?.destroy() + + // 4. 清空状态 + provider = null + ydoc = null + ytext = null + roomUserInfo.clear() + hasShownSuperAdminLeftMessage = false +} +``` + +## 工作流程 + +### 场景 1:超管先进入房间 + +```mermaid +sequenceDiagram + participant S as 超管 + participant R as Room + participant Y as Yjs + + S->>R: 加入房间 + S->>Y: 创建空文档 + S->>Y: 写入本地代码 + Note over S,Y: 等待学生加入 + + participant T as 学生 + T->>R: 加入房间 + T->>Y: 连接到文档 + Y->>T: 同步超管代码 + Note over S,T: 开始协同编辑 +``` + +### 场景 2:学生先进入房间 + +```mermaid +sequenceDiagram + participant T as 学生 + participant R as Room + participant Y as Yjs + + T->>R: 加入房间 + T->>Y: 创建空文档 + T->>Y: 写入本地代码 + Note over T,Y: 等待超管加入 + + participant S as 超管 + S->>R: 加入房间 + S->>Y: 连接到文档 + Y->>S: 同步学生代码 + Note over S,T: 开始协同编辑 +``` + +### 场景 3:超管离开 + +```mermaid +sequenceDiagram + participant S as 超管 + participant T as 学生 + participant R as Room + + Note over S,T: 正在协同编辑 + S->>R: 离开房间 + R->>T: 检测到超管离开 + T->>T: 显示警告 + T->>R: 自动断开连接 + Note over T: 协同编辑结束 +``` + +## API 文档 + +### useCodeSync() + +协同编辑核心 Composable + +**返回值:** + +```typescript +{ + startSync: (options: SyncOptions) => Promise<() => void> + stopSync: () => void + getInitialExtension: () => Extension +} +``` + +### startSync(options) + +启动协同编辑 + +**参数:** + +```typescript +interface SyncOptions { + problemId: string // 题目 ID(用于房间名) + editorView: EditorView // CodeMirror 编辑器实例 + onStatusChange?: (status: SyncStatus) => void // 状态变化回调 +} +``` + +**返回:** + +```typescript +Promise<() => void> // 返回清理函数 +``` + +**状态回调:** + +```typescript +interface SyncStatus { + connected: boolean // 是否已连接 + roomUsers: number // 房间人数 + canSync: boolean // 是否可以同步 + message: string // 状态消息 + error?: string // 错误信息 + otherUser?: { // 其他用户信息 + name: string + isSuperAdmin: boolean + } +} +``` + +### stopSync() + +停止协同编辑并清理资源 + +**用法:** + +```typescript +stopSync() +``` + +### getInitialExtension() + +获取初始的 Compartment 扩展(空扩展) + +**返回:** + +```typescript +Extension // CodeMirror 扩展 +``` + +## 使用示例 + +### 基础用法 + +```vue + +``` + +### 完整示例 + +参考项目中的文件: +- `src/shared/composables/sync.ts` - 核心实现 +- `src/shared/components/CodeEditor.vue` - 编辑器组件 +- `src/oj/problem/components/Editor.vue` - 容器组件 +- `src/oj/problem/components/Form.vue` - UI 控制 + +## 故障排查 + +### 问题 1:无法连接 + +**症状:** 显示"正在等待加入"但始终无法连接 + +**可能原因:** +1. 信令服务器不可用 +2. WebRTC 被防火墙阻止 +3. 网络问题 + +**解决方案:** +```typescript +// 检查信令服务器配置 +console.log(import.meta.env.PUBLIC_SIGNALING_URL) + +// 检查浏览器控制台的 WebRTC 错误 +``` + +### 问题 2:内容重复 + +**症状:** 第一个用户的代码被复制了两次 + +**原因:** yCollab 自动同步 + 手动插入 + +**解决方案:** +- 确保在应用 yCollab 扩展前清空编辑器 +- 只在 ytext 为空时写入内容 + +### 问题 3:超管离开但学生未断开 + +**症状:** 超管离开后学生仍显示连接中 + +**原因:** Awareness 变化事件未触发或未处理 + +**解决方案:** +```typescript +// 确保监听 awareness change 事件 +provider.awareness.on("change", (changes) => { + if (changes.removed?.length > 0) { + checkIfSuperAdminLeft(changes.removed) + } +}) +``` + +### 问题 4:光标不显示 + +**症状:** 开启同步后光标消失 + +**原因:** +1. 编辑器失去焦点 +2. 清空编辑器时未保持选区 +3. 扩展未正确应用 + +**解决方案:** +- 使用 Compartment 动态管理扩展 +- 避免手动操作编辑器 DOM +- 让 yCollab 自动处理内容同步 + +### 问题 5:第二个用户看不到第一个用户的代码 + +**症状:** 第二个进入的用户看到的是空白或自己的代码 + +**原因:** 内容同步逻辑错误 + +**解决方案:** +```typescript +// 第一个用户 +provider.on("synced", () => { + if (ytext.length === 0) { + ytext.insert(0, savedContent) // 写入内容 + } +}) + +// 第二个用户 +// yCollab 会自动同步 ytext 到编辑器 +// 无需手动操作 +``` + +## 性能优化 + +### 1. 按需加载 + +```typescript +// 只在需要时才加载 yjs 相关依赖 +const [Y, { WebrtcProvider }, { yCollab }] = await Promise.all([ + import("yjs"), + import("y-webrtc"), + import("y-codemirror.next"), +]) +``` + +**优势:** +- 减小初始包体积 +- 提升首屏加载速度 +- 不使用同步功能的用户不会下载这些依赖 + +### 2. 使用 Compartment + +```typescript +// 动态添加/移除扩展,无需重建编辑器 +const collabCompartment = new Compartment() + +// 添加扩展 +editorView.dispatch({ + effects: collabCompartment.reconfigure(collabExt) +}) + +// 移除扩展 +editorView.dispatch({ + effects: collabCompartment.reconfigure([]) +}) +``` + +**优势:** +- 保持编辑器状态 +- 避免重新创建编辑器的开销 +- 更流畅的用户体验 + +### 3. 防抖检查 + +```typescript +// 延迟检查权限,给 awareness 时间同步 +setTimeout(() => { + checkRoomPermissions(roomUsers) +}, AWARENESS_SYNC_DELAY) +``` + +## 安全考虑 + +### 1. 权限控制 + +- 房间必须有至少一个超级管理员 +- 学生无法单独建立协同会话 +- 超管离开时自动终止会话 + +### 2. 数据隔离 + +- 每个题目使用独立的房间 +- 房间名称:`problem-{problemId}` +- 最多 2 人限制 + +### 3. 用户验证 + +```typescript +if (!userStore.isAuthed) { + return error("请先登录") +} +``` + +## 配置 + +### 环境变量 + +```env +# 信令服务器地址 +PUBLIC_SIGNALING_URL=wss://your-signaling-server.com +``` + +### 常量配置 + +```typescript +const SYNC_CONSTANTS = { + MAX_ROOM_USERS: 2, // 最大房间人数 + AWARENESS_SYNC_DELAY: 500, // Awareness 同步延迟 (ms) + INIT_SYNC_TIMEOUT: 500, // 初始同步超时 (ms) + SUPER_ADMIN_COLOR: "#ff6b6b", // 超管光标颜色 + REGULAR_USER_COLOR: "#4dabf7", // 普通用户光标颜色 +} +``` + +## 扩展阅读 + +- [Yjs 官方文档](https://docs.yjs.dev/) +- [y-webrtc 文档](https://github.com/yjs/y-webrtc) +- [CodeMirror 6 文档](https://codemirror.net/docs/) +- [CRDT 介绍](https://crdt.tech/) + +## 更新日志 + +### v1.0.0 (2024-10) +- ✅ 初始实现 +- ✅ 按需加载优化 +- ✅ 完整的权限控制 +- ✅ 超管离开检测 +- ✅ 用户信息显示 + diff --git a/package-lock.json b/package-lock.json index 96fbdc9..de70a4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,10 @@ "vue": "^3.5.22", "vue-chartjs": "^5.3.2", "vue-codemirror": "^6.1.1", - "vue-router": "^4.5.1" + "vue-router": "^4.5.1", + "y-codemirror.next": "^0.3.5", + "y-webrtc": "^10.3.0", + "yjs": "^13.6.27" }, "devDependencies": { "@iconify/vue": "^5.0.0", @@ -466,6 +469,7 @@ "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -475,6 +479,7 @@ "resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.38.4.tgz", "integrity": "sha512-hduz0suCcUSC/kM8Fq3A9iLwInJDl8fD1xLpTIk+5xkNm8z/FT7UsIa9sOXrkpChh+XXc18RzswE8QqELsVl+g==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -2041,6 +2046,26 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.8.11", "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.11.tgz", @@ -2121,6 +2146,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2415,7 +2464,6 @@ "version": "4.4.3", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2495,6 +2543,12 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/err-code": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/err-code/-/err-code-3.0.1.tgz", + "integrity": "sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2845,6 +2899,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-browser-rtc": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/get-browser-rtc/-/get-browser-rtc-1.1.0.tgz", + "integrity": "sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==", + "license": "MIT" + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3025,6 +3085,26 @@ "@babel/runtime": "^7.23.2" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/immer": { "version": "9.0.21", "resolved": "https://registry.npmmirror.com/immer/-/immer-9.0.21.tgz", @@ -3035,6 +3115,12 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3115,6 +3201,16 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmmirror.com/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmmirror.com/jest-worker/-/jest-worker-27.5.1.tgz", @@ -3177,6 +3273,27 @@ "dev": true, "license": "MIT" }, + "node_modules/lib0": { + "version": "0.2.114", + "resolved": "https://registry.npmmirror.com/lib0/-/lib0-0.2.114.tgz", + "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz", @@ -3469,7 +3586,6 @@ "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/naive-ui": { @@ -3751,16 +3867,49 @@ ], "license": "MIT" }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", @@ -3807,7 +3956,6 @@ "version": "5.2.1", "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -3876,6 +4024,35 @@ "randombytes": "^2.1.0" } }, + "node_modules/simple-peer": { + "version": "9.11.1", + "resolved": "https://registry.npmmirror.com/simple-peer/-/simple-peer-9.11.1.tgz", + "integrity": "sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "debug": "^4.3.2", + "err-code": "^3.0.1", + "get-browser-rtc": "^1.1.0", + "queue-microtask": "^1.2.3", + "randombytes": "^2.1.0", + "readable-stream": "^3.6.0" + } + }, "node_modules/slate": { "version": "0.82.1", "resolved": "https://registry.npmmirror.com/slate/-/slate-0.82.1.tgz", @@ -3955,6 +4132,15 @@ "integrity": "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==", "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz", @@ -4316,6 +4502,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vdirs": { "version": "0.1.8", "resolved": "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz", @@ -4544,6 +4736,28 @@ "integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==", "license": "MIT" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xss": { "version": "1.0.15", "resolved": "https://registry.npmmirror.com/xss/-/xss-1.0.15.tgz", @@ -4559,6 +4773,89 @@ "engines": { "node": ">= 0.10.0" } + }, + "node_modules/y-codemirror.next": { + "version": "0.3.5", + "resolved": "https://registry.npmmirror.com/y-codemirror.next/-/y-codemirror.next-0.3.5.tgz", + "integrity": "sha512-VluNu3e5HfEXybnypnsGwKAj+fKLd4iAnR7JuX1Sfyydmn1jCBS5wwEL/uS04Ch2ib0DnMAOF6ZRR/8kK3wyGw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.42" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "yjs": "^13.5.6" + } + }, + "node_modules/y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/y-webrtc": { + "version": "10.3.0", + "resolved": "https://registry.npmmirror.com/y-webrtc/-/y-webrtc-10.3.0.tgz", + "integrity": "sha512-KalJr7dCgUgyVFxoG3CQYbpS0O2qybegD0vI4bYnYHI0MOwoVbucED3RZ5f2o1a5HZb1qEssUKS0H/Upc6p1lA==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.42", + "simple-peer": "^9.11.0", + "y-protocols": "^1.0.6" + }, + "bin": { + "y-webrtc-signaling": "bin/server.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "optionalDependencies": { + "ws": "^8.14.2" + }, + "peerDependencies": { + "yjs": "^13.6.8" + } + }, + "node_modules/yjs": { + "version": "13.6.27", + "resolved": "https://registry.npmmirror.com/yjs/-/yjs-13.6.27.tgz", + "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } } } } diff --git a/package.json b/package.json index b8dfcc8..8098f87 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,10 @@ "vue": "^3.5.22", "vue-chartjs": "^5.3.2", "vue-codemirror": "^6.1.1", - "vue-router": "^4.5.1" + "vue-router": "^4.5.1", + "y-codemirror.next": "^0.3.5", + "y-webrtc": "^10.3.0", + "yjs": "^13.6.27" }, "devDependencies": { "@iconify/vue": "^5.0.0", diff --git a/src/admin/user/components/Name.vue b/src/admin/user/components/Name.vue index 3e0923d..59c962b 100644 --- a/src/admin/user/components/Name.vue +++ b/src/admin/user/components/Name.vue @@ -2,6 +2,7 @@ import { PROBLEM_PERMISSION, USER_TYPE } from "~/utils/constants" import { getUserRole } from "~/utils/functions" import { User } from "~/utils/types" +import TextCopy from "~/shared/components/TextCopy.vue" interface Props { user: User @@ -30,6 +31,6 @@ const isNotRegularUser = computed( : "仅自己" }} - {{ props.user.username }} + {{ props.user.username }} diff --git a/src/admin/user/list.vue b/src/admin/user/list.vue index a81f349..55e4b00 100644 --- a/src/admin/user/list.vue +++ b/src/admin/user/list.vue @@ -14,6 +14,8 @@ import { import Actions from "./components/Actions.vue" import Name from "./components/Name.vue" import { PROBLEM_PERMISSION, USER_TYPE } from "~/utils/constants" +import { useRouteQuery } from "@vueuse/router" +import TextCopy from "~/shared/components/TextCopy.vue" const message = useMessage() @@ -23,9 +25,9 @@ interface UserQuery { } // 使用分页 composable -const { query, clearQuery } = usePagination({ - keyword: "", - type: "", +const { query } = usePagination({ + keyword: useRouteQuery("keyword", "").value, + type: useRouteQuery("type", "").value, }) const total = ref(0) @@ -56,6 +58,7 @@ const columns: DataTableColumn[] = [ title: "密码", key: "raw_password", width: 100, + render: (row) => h(TextCopy, () => row.raw_password), }, { title: "创建时间", @@ -72,7 +75,7 @@ const columns: DataTableColumn[] = [ ? parseTime(row.last_login, "YYYY-MM-DD HH:mm:ss") : "从未登录", }, - { title: "真名", key: "real_name", width: 100 }, + { title: "真名", key: "real_name", width: 100, render: (row) => h(TextCopy, () => row.real_name) }, { title: "邮箱", key: "email", width: 200 }, { key: "actions", diff --git a/src/env.d.ts b/src/env.d.ts index 1b1185f..4e48511 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -7,6 +7,7 @@ interface ImportMetaEnv { readonly PUBLIC_CODE_URL: string readonly PUBLIC_JUDGE0_URL: string readonly PUBLIC_ICONIFY_URL: string + readonly PUBLIC_SIGNALING_URL: string } interface ImportMeta { diff --git a/src/oj/problem/components/Editor.vue b/src/oj/problem/components/Editor.vue index 3c41e2d..abd6d6e 100644 --- a/src/oj/problem/components/Editor.vue +++ b/src/oj/problem/components/Editor.vue @@ -5,56 +5,90 @@ import { SOURCES } from "utils/constants" import CodeEditor from "~/shared/components/CodeEditor.vue" import { isDesktop } from "~/shared/composables/breakpoints" import storage from "~/utils/storage" +import { LANGUAGE } from "~/utils/types" import Form from "./Form.vue" const route = useRoute() -const contestID = !!route.params.contestID ? route.params.contestID : null +const formRef = useTemplateRef>("formRef") +const sync = ref(false) +const otherUserInfo = ref<{ name: string; isSuperAdmin: boolean }>() +const hadConnection = ref(false) + +const contestID = route.params.contestID || null const storageKey = computed( () => `problem_${problem.value!._id}_contest_${contestID}_lang_${code.language}`, ) -onMounted(() => { - if (storage.get(storageKey.value)) { - code.value = storage.get(storageKey.value) - } else { - code.value = - problem.value!.template[code.language] || SOURCES[code.language] - } -}) - const editorHeight = computed(() => isDesktop.value ? "calc(100vh - 133px)" : "calc(100vh - 172px)", ) -function changeCode(v: string) { +onMounted(() => { + const savedCode = storage.get(storageKey.value) + code.value = + savedCode || + problem.value!.template[code.language] || + SOURCES[code.language] +}) + +const changeCode = (v: string) => { storage.set(storageKey.value, v) } -function changeLanguage(v: string) { - if ( - storage.get(storageKey.value) && - storageKey.value.split("_").pop() === v - ) { - code.value = storage.get(storageKey.value) - } else { - code.value = - problem.value!.template[code.language] || SOURCES[code.language] +const changeLanguage = (v: LANGUAGE) => { + const savedCode = storage.get(storageKey.value) + code.value = + savedCode && storageKey.value.split("_").pop() === v + ? savedCode + : problem.value!.template[code.language] || SOURCES[code.language] +} + +const toggleSync = (value: boolean) => { + sync.value = value + if (!value) { + hadConnection.value = false + } +} + +const handleSyncClosed = () => { + sync.value = false + otherUserInfo.value = undefined + hadConnection.value = false + formRef.value?.resetSyncStatus() +} + +const handleSyncStatusChange = (status: { + otherUser?: { name: string; isSuperAdmin: boolean } +}) => { + otherUserInfo.value = status.otherUser + if (status.otherUser) { + hadConnection.value = true } } - - diff --git a/src/oj/problem/components/Form.vue b/src/oj/problem/components/Form.vue index 6930f7a..2298ef1 100644 --- a/src/oj/problem/components/Form.vue +++ b/src/oj/problem/components/Form.vue @@ -15,151 +15,187 @@ import IconButton from "~/shared/components/IconButton.vue" interface Props { storageKey: string withTest?: boolean + otherUserInfo?: { name: string; isSuperAdmin: boolean } + isSynced?: boolean + hadConnection?: boolean } const props = withDefaults(defineProps(), { withTest: false, + isSynced: false, + hadConnection: false, }) +const emit = defineEmits<{ + changeLanguage: [v: LANGUAGE] + toggleSync: [v: boolean] +}>() + const message = useMessage() const route = useRoute() const router = useRouter() const userStore = useUserStore() -const emit = defineEmits(["changeLanguage"]) - +const isSynced = ref(false) const statisticPanel = ref(false) -function copy() { - copyText(code.value) - message.success("代码复制成功") -} - -function reset() { - code.value = problem.value!.template[code.language] || SOURCES[code.language] - storage.remove(props.storageKey) - message.success("代码重置成功") -} - -function goSubmissions() { - const name = !!route.params.contestID ? "contest submissions" : "submissions" - router.push({ name, query: { problem: problem.value!._id } }) -} - -function goEdit() { - let data = router.resolve("/admin/problem/edit/" + problem.value!.id) - if (problem.value!.contest) { - data = router.resolve( - `/admin/contest/${problem.value!.contest}/problem/edit/${problem.value!.id}`, - ) - } - window.open(data.href, "_blank") -} - -async function test() { - const res = await createTestSubmission(code, input.value) - output.value = res.output -} - const menu = computed(() => [ { label: "去自测猫", key: "test", show: isMobile.value }, { label: "复制代码", key: "copy" }, { label: "重置代码", key: "reset" }, ]) -const options: DropdownOption[] = problem.value!.languages.map((it) => ({ - label: () => [ - h("img", { - src: `/${it}.svg`, - style: { - width: "16px", - height: "16px", - marginRight: "8px", - transform: "translateY(3px)", - }, - }), - LANGUAGE_SHOW_VALUE[it], - ], +const languageOptions: DropdownOption[] = problem.value!.languages.map((it) => ({ + label: () => + h("div", { style: "display: flex; align-items: center;" }, [ + h("img", { + src: `/${it}.svg`, + style: { width: "16px", height: "16px", marginRight: "8px" }, + }), + LANGUAGE_SHOW_VALUE[it], + ]), value: it, })) -async function select(key: string) { - switch (key) { - case "reset": - reset() - break - case "copy": - copy() - break - case "test": - window.open(import.meta.env.PUBLIC_CODE_URL, "_blank") - break - } +const copy = () => { + copyText(code.value) + message.success("代码复制成功") } -function changeLanguage(v: LANGUAGE) { +const reset = () => { + code.value = problem.value!.template[code.language] || SOURCES[code.language] + storage.remove(props.storageKey) + message.success("代码重置成功") +} + +const goSubmissions = () => { + const name = route.params.contestID ? "contest submissions" : "submissions" + router.push({ name, query: { problem: problem.value!._id } }) +} + +const goEdit = () => { + const baseUrl = "/admin/problem/edit/" + problem.value!.id + const url = problem.value!.contest + ? `/admin/contest/${problem.value!.contest}/problem/edit/${problem.value!.id}` + : baseUrl + window.open(router.resolve(url).href, "_blank") +} + +const test = async () => { + const res = await createTestSubmission(code, input.value) + output.value = res.output +} + +const handleMenuSelect = (key: string) => { + const actions: Record void> = { + reset, + copy, + test: () => window.open(import.meta.env.PUBLIC_CODE_URL, "_blank"), + } + actions[key]?.() +} + +const changeLanguage = (v: LANGUAGE) => { storage.set(STORAGE_KEY.LANGUAGE, v) emit("changeLanguage", v) } -function gotoTestCat() { - const url = import.meta.env.PUBLIC_CODE_URL - window.open(url, "_blank") +const toggleSync = () => { + isSynced.value = !isSynced.value + emit("toggleSync", isSynced.value) } -function showStatisticsPanel() { - statisticPanel.value = true +const gotoTestCat = () => { + window.open(import.meta.env.PUBLIC_CODE_URL, "_blank") } + +defineExpose({ + resetSyncStatus: () => { + isSynced.value = false + }, +})