协同编辑代码
This commit is contained in:
3
.env
3
.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
|
||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_SIGNALING_URL=wss://ojsignaling.xuyue.cc
|
||||
@@ -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
|
||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_SIGNALING_URL=wss://ojsignaling.xuyue.cc
|
||||
@@ -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
|
||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
||||
PUBLIC_SIGNALING_URL=wss://ojsignaling.xuyue.cc
|
||||
@@ -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
|
||||
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098
|
||||
PUBLIC_SIGNALING_URL=wss://ojsignaling.xuyue.cc
|
||||
678
docs/SYNC_COLLABORATION.md
Normal file
678
docs/SYNC_COLLABORATION.md
Normal file
@@ -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
|
||||
<script setup>
|
||||
import { useCodeSync } from '~/shared/composables/sync'
|
||||
|
||||
const { startSync, stopSync, getInitialExtension } = useCodeSync()
|
||||
const editorView = ref(null)
|
||||
let cleanup = null
|
||||
|
||||
// 启动同步
|
||||
async function enableSync() {
|
||||
cleanup = await startSync({
|
||||
problemId: 'problem-123',
|
||||
editorView: editorView.value,
|
||||
onStatusChange: (status) => {
|
||||
console.log('同步状态:', status)
|
||||
|
||||
if (status.canSync) {
|
||||
console.log('可以开始协作了!')
|
||||
}
|
||||
|
||||
if (status.otherUser) {
|
||||
console.log('正在与', status.otherUser.name, '同步')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 停止同步
|
||||
function disableSync() {
|
||||
cleanup?.()
|
||||
stopSync()
|
||||
}
|
||||
|
||||
// 编辑器扩展
|
||||
const extensions = computed(() => [
|
||||
// ... 其他扩展
|
||||
getInitialExtension(), // 协同编辑扩展
|
||||
])
|
||||
</script>
|
||||
```
|
||||
|
||||
### 完整示例
|
||||
|
||||
参考项目中的文件:
|
||||
- `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)
|
||||
- ✅ 初始实现
|
||||
- ✅ 按需加载优化
|
||||
- ✅ 完整的权限控制
|
||||
- ✅ 超管离开检测
|
||||
- ✅ 用户信息显示
|
||||
|
||||
307
package-lock.json
generated
307
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
: "仅自己"
|
||||
}}
|
||||
</n-tag>
|
||||
{{ props.user.username }}
|
||||
<TextCopy>{{ props.user.username }}</TextCopy>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
@@ -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<UserQuery>({
|
||||
keyword: "",
|
||||
type: "",
|
||||
const { query } = usePagination<UserQuery>({
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
type: useRouteQuery("type", "").value,
|
||||
})
|
||||
|
||||
const total = ref(0)
|
||||
@@ -56,6 +58,7 @@ const columns: DataTableColumn<User>[] = [
|
||||
title: "密码",
|
||||
key: "raw_password",
|
||||
width: 100,
|
||||
render: (row) => h(TextCopy, () => row.raw_password),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
@@ -72,7 +75,7 @@ const columns: DataTableColumn<User>[] = [
|
||||
? 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",
|
||||
|
||||
1
src/env.d.ts
vendored
1
src/env.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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<InstanceType<typeof Form>>("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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<Form :storage-key="storageKey" @change-language="changeLanguage" />
|
||||
<Form
|
||||
ref="formRef"
|
||||
:storage-key="storageKey"
|
||||
:other-user-info="otherUserInfo"
|
||||
:is-synced="sync"
|
||||
:had-connection="hadConnection"
|
||||
@change-language="changeLanguage"
|
||||
@toggle-sync="toggleSync"
|
||||
/>
|
||||
<CodeEditor
|
||||
v-model:value="code.value"
|
||||
@update:model-value="changeCode"
|
||||
:sync="sync"
|
||||
:problem="problem!._id"
|
||||
:language="code.language"
|
||||
:height="editorHeight"
|
||||
@update:model-value="changeCode"
|
||||
@sync-closed="handleSyncClosed"
|
||||
@sync-status-change="handleSyncStatusChange"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -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<Props>(), {
|
||||
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<DropdownOption[]>(() => [
|
||||
{ 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<string, () => 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
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<n-select
|
||||
class="language"
|
||||
v-model:value="code.language"
|
||||
@update:value="changeLanguage"
|
||||
class="language"
|
||||
:size="isDesktop ? 'medium' : 'small'"
|
||||
:options="options"
|
||||
:options="languageOptions"
|
||||
@update:value="changeLanguage"
|
||||
/>
|
||||
<n-button v-if="withTest" @click="reset">重置代码</n-button>
|
||||
<n-button v-if="withTest" type="primary" secondary @click="test">
|
||||
运行代码
|
||||
</n-button>
|
||||
<n-flex align="center" v-if="!withTest">
|
||||
|
||||
<template v-if="withTest">
|
||||
<n-button @click="reset">重置代码</n-button>
|
||||
<n-button type="primary" secondary @click="test">运行代码</n-button>
|
||||
</template>
|
||||
|
||||
<n-flex v-else align="center">
|
||||
<Submit />
|
||||
<n-button v-if="isDesktop" @click="gotoTestCat">自测猫</n-button>
|
||||
|
||||
<n-button
|
||||
:size="isDesktop ? 'medium' : 'small'"
|
||||
v-if="!userStore.isSuperAdmin && userStore.showSubmissions"
|
||||
:size="isDesktop ? 'medium' : 'small'"
|
||||
@click="goSubmissions"
|
||||
>
|
||||
提交信息
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
:size="isDesktop ? 'medium' : 'small'"
|
||||
v-if="userStore.isSuperAdmin"
|
||||
@click="showStatisticsPanel"
|
||||
:size="isDesktop ? 'medium' : 'small'"
|
||||
@click="statisticPanel = true"
|
||||
>
|
||||
{{ isDesktop ? "统计信息" : "统计" }}
|
||||
</n-button>
|
||||
<n-dropdown size="large" :options="menu" @select="select">
|
||||
|
||||
<n-button v-if="isDesktop" @click="gotoTestCat">自测猫</n-button>
|
||||
|
||||
<n-dropdown size="large" :options="menu" @select="handleMenuSelect">
|
||||
<n-button :size="isDesktop ? 'medium' : 'small'">操作</n-button>
|
||||
</n-dropdown>
|
||||
|
||||
<IconButton
|
||||
v-if="isDesktop && userStore.isSuperAdmin"
|
||||
icon="streamline-ultimate-color:file-code-edit"
|
||||
tip="编辑题目"
|
||||
v-if="isDesktop && userStore.isSuperAdmin"
|
||||
@click="goEdit"
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
v-if="isDesktop && userStore.isAuthed"
|
||||
:icon="
|
||||
isSynced
|
||||
? 'streamline-stickies-color:earpod-connected'
|
||||
: 'streamline-stickies-color:earpod-connected-duo'
|
||||
"
|
||||
:tip="isSynced ? '断开同步' : '打开同步'"
|
||||
:type="isSynced ? 'info' : 'default'"
|
||||
@click="toggleSync"
|
||||
/>
|
||||
|
||||
<template v-if="props.isSynced">
|
||||
<n-tag v-if="otherUserInfo" type="info">
|
||||
与 {{ otherUserInfo.name }} 同步中
|
||||
</n-tag>
|
||||
<n-tag
|
||||
v-if="userStore.isSuperAdmin && !otherUserInfo && hadConnection"
|
||||
type="warning"
|
||||
>
|
||||
学生已退出,可以关闭同步
|
||||
</n-tag>
|
||||
</template>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
|
||||
<n-modal
|
||||
v-if="userStore.isSuperAdmin"
|
||||
v-model:show="statisticPanel"
|
||||
preset="card"
|
||||
title="提交记录的统计"
|
||||
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
|
||||
:content-style="{ overflow: 'auto' }"
|
||||
title="提交记录的统计"
|
||||
>
|
||||
<StatisticsPanel :problem="problem!._id" username="" />
|
||||
</n-modal>
|
||||
|
||||
@@ -3,14 +3,12 @@ import { NButton } from "naive-ui"
|
||||
import { getSubmissions, getRankOfProblem } from "~/oj/api"
|
||||
import Pagination from "~/shared/components/Pagination.vue"
|
||||
import SubmissionResultTag from "~/shared/components/SubmissionResultTag.vue"
|
||||
import { useConfigStore } from "~/shared/store/config"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
import { LANGUAGE_SHOW_VALUE } from "~/utils/constants"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { renderTableTitle } from "~/utils/renders"
|
||||
import { Submission } from "~/utils/types"
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -117,7 +115,7 @@ watch(query, listSubmissions)
|
||||
<n-alert
|
||||
class="tip"
|
||||
type="error"
|
||||
v-if="!userStore.showSubmissions"
|
||||
v-if="!userStore.showSubmissions || !userStore.isAuthed"
|
||||
:title="errorMsg"
|
||||
/>
|
||||
|
||||
@@ -126,11 +124,12 @@ watch(query, listSubmissions)
|
||||
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<div>
|
||||
<span>
|
||||
本道题你在班上排名第 <b>{{ rank }}</b
|
||||
>,你们班共有 <b>{{ class_ac_count }}</b> 人答案正确
|
||||
</div>
|
||||
</span>
|
||||
<n-button
|
||||
secondary
|
||||
v-if="userStore.showSubmissions"
|
||||
@click="
|
||||
router.push({
|
||||
@@ -157,12 +156,10 @@ watch(query, listSubmissions)
|
||||
v-if="rank === -1 && class_ac_count > 0"
|
||||
>
|
||||
<template #header>
|
||||
<n-flex>
|
||||
<div>
|
||||
本道题你还没有解决,
|
||||
<div v-if="class_name">你们班</div>
|
||||
共有 <b>{{ class_ac_count }}</b> 人答案正确
|
||||
</div>
|
||||
<n-flex align="center">
|
||||
<span>
|
||||
本道题你还没有解决,你们班共有 <b>{{ class_ac_count }}</b> 人答案正确
|
||||
</span>
|
||||
<n-button
|
||||
v-if="userStore.showSubmissions"
|
||||
secondary
|
||||
@@ -189,11 +186,10 @@ watch(query, listSubmissions)
|
||||
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<div>
|
||||
<span>
|
||||
本道题你在全服排名第 <b>{{ rank }}</b
|
||||
>,全服共有 <b>{{ all_ac_count }}</b> 人答案正确
|
||||
</div>
|
||||
<div></div>
|
||||
</span>
|
||||
<n-button
|
||||
secondary
|
||||
v-if="userStore.showSubmissions"
|
||||
@@ -222,10 +218,9 @@ watch(query, listSubmissions)
|
||||
>
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<div>
|
||||
本道题你还没有解决,全服共有
|
||||
<b>{{ all_ac_count }}</b> 人答案正确
|
||||
</div>
|
||||
<span>
|
||||
本道题你还没有解决,全服共有 <b>{{ all_ac_count }}</b> 人答案正确
|
||||
</span>
|
||||
<n-button
|
||||
v-if="userStore.showSubmissions"
|
||||
secondary
|
||||
@@ -249,8 +244,8 @@ watch(query, listSubmissions)
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="userStore.showSubmissions">
|
||||
<n-data-table striped :columns="columns" :data="submissions" />
|
||||
<template v-if="userStore.showSubmissions && userStore.isAuthed">
|
||||
<n-data-table v-if="submissions.length > 0" striped :columns="columns" :data="submissions" />
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
|
||||
@@ -3,23 +3,21 @@ import { cpp } from "@codemirror/lang-cpp"
|
||||
import { python } from "@codemirror/lang-python"
|
||||
import { EditorView } from "@codemirror/view"
|
||||
import { Codemirror } from "vue-codemirror"
|
||||
import type { Extension } from "@codemirror/state"
|
||||
import { LANGUAGE } from "~/utils/types"
|
||||
import { oneDark } from "../themes/oneDark"
|
||||
import { smoothy } from "../themes/smoothy"
|
||||
import { useCodeSync } from "../composables/sync"
|
||||
|
||||
const styleTheme = EditorView.baseTheme({
|
||||
"& .cm-scroller": {
|
||||
"font-family": "Monaco",
|
||||
},
|
||||
"&.cm-editor.cm-focused": {
|
||||
outline: "none",
|
||||
},
|
||||
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
|
||||
"font-family": "Monaco",
|
||||
},
|
||||
})
|
||||
interface EditorReadyPayload {
|
||||
view: EditorView
|
||||
state: any
|
||||
container: HTMLElement
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sync?: boolean
|
||||
problem?: string
|
||||
language?: LANGUAGE
|
||||
fontSize?: number
|
||||
height?: string
|
||||
@@ -28,6 +26,8 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
sync: false,
|
||||
problem: "",
|
||||
language: "Python3",
|
||||
fontSize: 20,
|
||||
height: "100%",
|
||||
@@ -35,25 +35,105 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: "",
|
||||
})
|
||||
|
||||
const { readonly, placeholder, height, fontSize } = toRefs(props)
|
||||
const code = defineModel<string>("value")
|
||||
|
||||
const emit = defineEmits<{
|
||||
syncClosed: []
|
||||
syncStatusChange: [
|
||||
status: { otherUser?: { name: string; isSuperAdmin: boolean } },
|
||||
]
|
||||
}>()
|
||||
|
||||
const isDark = useDark()
|
||||
|
||||
const lang = computed(() => {
|
||||
if (["Python2", "Python3"].includes(props.language)) {
|
||||
return python()
|
||||
}
|
||||
return cpp()
|
||||
const styleTheme = EditorView.baseTheme({
|
||||
"& .cm-scroller": { "font-family": "Monaco" },
|
||||
"&.cm-editor.cm-focused": { outline: "none" },
|
||||
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
|
||||
"font-family": "Monaco",
|
||||
},
|
||||
})
|
||||
|
||||
const lang = computed((): Extension => {
|
||||
return ["Python2", "Python3"].includes(props.language) ? python() : cpp()
|
||||
})
|
||||
|
||||
const extensions = computed((): Extension[] => [
|
||||
styleTheme,
|
||||
lang.value,
|
||||
isDark.value ? oneDark : smoothy,
|
||||
getInitialExtension(),
|
||||
])
|
||||
|
||||
const { startSync, stopSync, getInitialExtension } = useCodeSync()
|
||||
const editorView = ref<EditorView | null>(null)
|
||||
let cleanupSync: (() => void) | null = null
|
||||
|
||||
const cleanupSyncResources = () => {
|
||||
if (cleanupSync) {
|
||||
cleanupSync()
|
||||
cleanupSync = null
|
||||
}
|
||||
stopSync()
|
||||
}
|
||||
|
||||
const initSync = async () => {
|
||||
if (!editorView.value || !props.problem) return
|
||||
|
||||
cleanupSyncResources()
|
||||
|
||||
cleanupSync = await startSync({
|
||||
problemId: props.problem,
|
||||
editorView: editorView.value as EditorView,
|
||||
onStatusChange: (status) => {
|
||||
if (status.error === "超管已离开" && !status.connected) {
|
||||
emit("syncClosed")
|
||||
}
|
||||
emit("syncStatusChange", { otherUser: status.otherUser })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditorReady = (payload: EditorReadyPayload) => {
|
||||
editorView.value = payload.view as EditorView
|
||||
if (props.sync && props.problem) {
|
||||
initSync()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.sync,
|
||||
(shouldSync) => {
|
||||
if (shouldSync && props.problem && editorView.value) {
|
||||
initSync()
|
||||
} else {
|
||||
cleanupSyncResources()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.problem,
|
||||
(newProblem, oldProblem) => {
|
||||
if (newProblem !== oldProblem && props.sync && editorView.value) {
|
||||
initSync()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(cleanupSyncResources)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Codemirror
|
||||
v-model="code"
|
||||
indentWithTab
|
||||
:extensions="[styleTheme, lang, isDark ? oneDark : smoothy]"
|
||||
:disabled="props.readonly"
|
||||
:tabSize="4"
|
||||
:placeholder="props.placeholder"
|
||||
:style="{ height: props.height, fontSize: props.fontSize + 'px' }"
|
||||
:extensions="extensions"
|
||||
:disabled="readonly"
|
||||
:tab-size="4"
|
||||
:placeholder="placeholder"
|
||||
:style="{ height, fontSize: `${fontSize}px` }"
|
||||
@ready="handleEditorReady"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button circle @click="$emit('click')">
|
||||
<n-button circle :type="type ?? 'default'" @click="$emit('click')">
|
||||
<template #icon>
|
||||
<Icon :icon="icon" />
|
||||
</template>
|
||||
@@ -16,6 +16,14 @@ import { Icon } from "@iconify/vue"
|
||||
defineProps<{
|
||||
tip: string
|
||||
icon: string
|
||||
type?:
|
||||
| "default"
|
||||
| "tertiary"
|
||||
| "primary"
|
||||
| "info"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "error"
|
||||
}>()
|
||||
defineEmits(["click"])
|
||||
</script>
|
||||
|
||||
29
src/shared/components/TextCopy.vue
Normal file
29
src/shared/components/TextCopy.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button text @click="handleClick">
|
||||
<slot />
|
||||
</n-button>
|
||||
</template>
|
||||
点击复制
|
||||
</n-tooltip>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import copyText from "copy-text-to-clipboard"
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
function handleClick() {
|
||||
const textToCopy = getTextFromSlot()
|
||||
copyText(textToCopy)
|
||||
message.success("已复制")
|
||||
}
|
||||
|
||||
function getTextFromSlot() {
|
||||
const vnodes = slots.default?.()
|
||||
if (!vnodes) return ""
|
||||
return vnodes.map((vnode) => vnode.children).join("")
|
||||
}
|
||||
</script>
|
||||
397
src/shared/composables/sync.ts
Normal file
397
src/shared/composables/sync.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import { useUserStore } from "../store/user"
|
||||
import type { EditorView } from "@codemirror/view"
|
||||
import { Compartment } from "@codemirror/state"
|
||||
import type { WebrtcProvider } from "y-webrtc"
|
||||
import type { Doc, Text } from "yjs"
|
||||
|
||||
// 常量定义
|
||||
const SYNC_CONSTANTS = {
|
||||
MAX_ROOM_USERS: 2,
|
||||
AWARENESS_SYNC_DELAY: 500,
|
||||
INIT_SYNC_TIMEOUT: 500,
|
||||
SUPER_ADMIN_COLOR: "#ff6b6b",
|
||||
REGULAR_USER_COLOR: "#4dabf7",
|
||||
} as const
|
||||
|
||||
// 类型定义
|
||||
type SyncState = "waiting" | "active" | "error"
|
||||
|
||||
interface UserInfo {
|
||||
name: string
|
||||
isSuperAdmin: boolean
|
||||
}
|
||||
|
||||
interface PeersEvent {
|
||||
added: string[]
|
||||
removed: string[]
|
||||
webrtcPeers: string[]
|
||||
}
|
||||
|
||||
interface StatusEvent {
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
interface SyncedEvent {
|
||||
synced: boolean
|
||||
}
|
||||
|
||||
interface SyncOptions {
|
||||
problemId: string
|
||||
editorView: EditorView
|
||||
onStatusChange?: (status: SyncStatus) => void
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
connected: boolean
|
||||
roomUsers: number
|
||||
canSync: boolean
|
||||
message: string
|
||||
error?: string
|
||||
otherUser?: UserInfo
|
||||
}
|
||||
|
||||
export function useCodeSync() {
|
||||
const userStore = useUserStore()
|
||||
const message = useMessage()
|
||||
|
||||
// 状态变量
|
||||
let ydoc: Doc | null = null
|
||||
let provider: WebrtcProvider | null = null
|
||||
let ytext: Text | null = null
|
||||
const collabCompartment = new Compartment()
|
||||
let currentEditorView: EditorView | null = null
|
||||
let lastSyncState: SyncState | null = null
|
||||
let roomUserInfo = new Map<number, UserInfo>()
|
||||
let hasShownSuperAdminLeftMessage = false
|
||||
|
||||
const updateStatus = (
|
||||
status: SyncStatus,
|
||||
onStatusChange?: (status: SyncStatus) => void,
|
||||
) => {
|
||||
onStatusChange?.(status)
|
||||
}
|
||||
|
||||
const normalizeClientId = (clientId: number | string): number => {
|
||||
return typeof clientId === "string" ? parseInt(clientId, 10) : clientId
|
||||
}
|
||||
|
||||
const checkHasSuperAdmin = (awarenessStates: Map<number, any>): boolean => {
|
||||
if (userStore.isSuperAdmin) return true
|
||||
return Array.from(awarenessStates.values()).some(
|
||||
(state) => state.user?.isSuperAdmin,
|
||||
)
|
||||
}
|
||||
|
||||
const getOtherUserInfo = (
|
||||
awarenessStates: Map<number, any>,
|
||||
): UserInfo | undefined => {
|
||||
if (!provider) return undefined
|
||||
|
||||
const localClientId = provider.awareness.clientID
|
||||
for (const [clientId, state] of awarenessStates) {
|
||||
if (clientId !== localClientId && state.user) {
|
||||
return {
|
||||
name: state.user.name,
|
||||
isSuperAdmin: state.user.isSuperAdmin,
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const checkIfSuperAdminLeft = (
|
||||
removedClientIds: number[],
|
||||
onStatusChange?: (status: SyncStatus) => void,
|
||||
) => {
|
||||
if (userStore.isSuperAdmin || hasShownSuperAdminLeftMessage) return
|
||||
|
||||
const superAdminInfo = removedClientIds
|
||||
.map((id) => roomUserInfo.get(id))
|
||||
.find((info) => info?.isSuperAdmin)
|
||||
|
||||
if (superAdminInfo) {
|
||||
hasShownSuperAdminLeftMessage = true
|
||||
updateStatus(
|
||||
{
|
||||
connected: false,
|
||||
roomUsers: 0,
|
||||
canSync: false,
|
||||
message: `超管 ${superAdminInfo.name} 已退出`,
|
||||
error: "超管已离开",
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
message.warning(`超管 ${superAdminInfo.name} 已退出`)
|
||||
stopSync()
|
||||
}
|
||||
}
|
||||
|
||||
const checkRoomPermissions = (
|
||||
roomUsers: number,
|
||||
onStatusChange?: (status: SyncStatus) => void,
|
||||
) => {
|
||||
const awarenessStates = provider?.awareness.getStates()
|
||||
if (!awarenessStates) return
|
||||
|
||||
const hasSuperAdmin = checkHasSuperAdmin(awarenessStates)
|
||||
const canSync = roomUsers === SYNC_CONSTANTS.MAX_ROOM_USERS && hasSuperAdmin
|
||||
const otherUser = getOtherUserInfo(awarenessStates)
|
||||
|
||||
if (roomUsers === SYNC_CONSTANTS.MAX_ROOM_USERS && !hasSuperAdmin) {
|
||||
updateStatus(
|
||||
{
|
||||
connected: true,
|
||||
roomUsers,
|
||||
canSync: false,
|
||||
message: "房间内必须有一个超级管理员",
|
||||
error: "缺少超级管理员",
|
||||
otherUser,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
if (lastSyncState !== "error") {
|
||||
message.warning("协同编辑需要至少一个超级管理员")
|
||||
lastSyncState = "error"
|
||||
}
|
||||
} else if (canSync) {
|
||||
updateStatus(
|
||||
{
|
||||
connected: true,
|
||||
roomUsers,
|
||||
canSync: true,
|
||||
message: "协同编辑已激活,可以开始协作!",
|
||||
otherUser,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
if (lastSyncState !== "active") {
|
||||
message.success("协同编辑已激活,可以开始协作!")
|
||||
lastSyncState = "active"
|
||||
}
|
||||
} else {
|
||||
updateStatus(
|
||||
{
|
||||
connected: true,
|
||||
roomUsers,
|
||||
canSync: false,
|
||||
message: roomUsers === 1 ? "正在等待加入" : "等待超级管理员加入...",
|
||||
otherUser,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
lastSyncState = "waiting"
|
||||
}
|
||||
}
|
||||
|
||||
const setupContentSync = (
|
||||
editorView: EditorView,
|
||||
ytext: Text,
|
||||
provider: WebrtcProvider,
|
||||
savedContent: string,
|
||||
) => {
|
||||
let hasInitialized = false
|
||||
|
||||
const initTimeout = setTimeout(() => {
|
||||
if (!hasInitialized && ytext.length === 0 && savedContent) {
|
||||
ytext.insert(0, savedContent)
|
||||
}
|
||||
hasInitialized = true
|
||||
}, SYNC_CONSTANTS.INIT_SYNC_TIMEOUT)
|
||||
|
||||
provider.on("synced", (event: SyncedEvent) => {
|
||||
if (!event.synced || hasInitialized) return
|
||||
|
||||
clearTimeout(initTimeout)
|
||||
if (ytext.length === 0 && savedContent) {
|
||||
ytext.insert(0, savedContent)
|
||||
}
|
||||
hasInitialized = true
|
||||
})
|
||||
}
|
||||
|
||||
async function startSync(options: SyncOptions): Promise<() => void> {
|
||||
const { problemId, editorView, onStatusChange } = options
|
||||
|
||||
if (!userStore.isAuthed) {
|
||||
updateStatus(
|
||||
{
|
||||
connected: false,
|
||||
roomUsers: 0,
|
||||
canSync: false,
|
||||
message: "请先登录后再使用同步功能",
|
||||
error: "用户未登录",
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
message.error("请先登录后再使用同步功能")
|
||||
return () => {}
|
||||
}
|
||||
|
||||
// 动态导入 yjs 相关模块
|
||||
const [Y, { WebrtcProvider }, { yCollab }] = await Promise.all([
|
||||
import("yjs"),
|
||||
import("y-webrtc"),
|
||||
import("y-codemirror.next"),
|
||||
])
|
||||
|
||||
// 初始化文档和提供者
|
||||
ydoc = new Y.Doc()
|
||||
ytext = ydoc.getText("codemirror")
|
||||
const roomName = `problem-${problemId}`
|
||||
|
||||
provider = new WebrtcProvider(roomName, ydoc, {
|
||||
signaling: [import.meta.env.PUBLIC_SIGNALING_URL],
|
||||
maxConns: 1,
|
||||
filterBcConns: true,
|
||||
})
|
||||
|
||||
// 监听连接状态
|
||||
provider.on("status", (event: StatusEvent) => {
|
||||
if (!event.connected) {
|
||||
updateStatus(
|
||||
{
|
||||
connected: false,
|
||||
roomUsers: 0,
|
||||
canSync: false,
|
||||
message: "连接已断开",
|
||||
error: "WebRTC 连接断开",
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
message.warning("协同编辑连接已断开")
|
||||
}
|
||||
})
|
||||
|
||||
// 监听用户加入/离开
|
||||
provider.on("peers", (event: PeersEvent) => {
|
||||
const roomUsers = event.webrtcPeers.length + 1
|
||||
|
||||
if (roomUsers > SYNC_CONSTANTS.MAX_ROOM_USERS) {
|
||||
updateStatus(
|
||||
{
|
||||
connected: false,
|
||||
roomUsers,
|
||||
canSync: false,
|
||||
message: "房间人数已满,已自动断开连接",
|
||||
error: `房间最多只能有${SYNC_CONSTANTS.MAX_ROOM_USERS}个人`,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
message.warning(
|
||||
`房间人数已满(最多${SYNC_CONSTANTS.MAX_ROOM_USERS}人),已自动断开连接`,
|
||||
)
|
||||
stopSync()
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
checkRoomPermissions(roomUsers, onStatusChange)
|
||||
}, SYNC_CONSTANTS.AWARENESS_SYNC_DELAY)
|
||||
})
|
||||
|
||||
// 监听 awareness 变化
|
||||
provider.awareness.on("change", (changes: any) => {
|
||||
if (!provider) return
|
||||
|
||||
const awarenessStates = provider.awareness.getStates()
|
||||
|
||||
if (changes.removed?.length > 0) {
|
||||
checkIfSuperAdminLeft(changes.removed, onStatusChange)
|
||||
}
|
||||
|
||||
awarenessStates.forEach((state, clientId) => {
|
||||
if (state.user) {
|
||||
const normalizedId = normalizeClientId(clientId)
|
||||
roomUserInfo.set(normalizedId, {
|
||||
name: state.user.name,
|
||||
isSuperAdmin: state.user.isSuperAdmin,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
checkRoomPermissions(awarenessStates.size, onStatusChange)
|
||||
})
|
||||
|
||||
// 配置编辑器扩展
|
||||
if (editorView && ytext) {
|
||||
currentEditorView = editorView
|
||||
const userColor = userStore.isSuperAdmin
|
||||
? SYNC_CONSTANTS.SUPER_ADMIN_COLOR
|
||||
: SYNC_CONSTANTS.REGULAR_USER_COLOR
|
||||
const userName = userStore.user?.username || "匿名用户"
|
||||
const savedContent = editorView.state.doc.toString()
|
||||
|
||||
// 设置用户信息
|
||||
provider.awareness.setLocalStateField("user", {
|
||||
name: userName,
|
||||
color: userColor,
|
||||
isSuperAdmin: userStore.isSuperAdmin,
|
||||
})
|
||||
|
||||
// 清空编辑器并应用协同扩展
|
||||
editorView.dispatch({
|
||||
changes: { from: 0, to: editorView.state.doc.length, insert: "" },
|
||||
})
|
||||
|
||||
const collabExt = yCollab(ytext, provider.awareness)
|
||||
editorView.dispatch({
|
||||
effects: collabCompartment.reconfigure(collabExt),
|
||||
})
|
||||
|
||||
// 设置内容同步
|
||||
setupContentSync(editorView, ytext, provider, savedContent)
|
||||
|
||||
// 设置初始状态
|
||||
updateStatus(
|
||||
{
|
||||
connected: true,
|
||||
roomUsers: 1,
|
||||
canSync: false,
|
||||
message: "正在等待加入",
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
|
||||
message.info(
|
||||
userStore.isSuperAdmin ? "正在等待学生加入..." : "正在等待超管加入...",
|
||||
)
|
||||
lastSyncState = "waiting"
|
||||
}
|
||||
|
||||
return () => stopSync()
|
||||
}
|
||||
|
||||
function stopSync() {
|
||||
if (currentEditorView) {
|
||||
try {
|
||||
currentEditorView.dispatch({
|
||||
effects: collabCompartment.reconfigure([]),
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("移除协同编辑扩展失败:", error)
|
||||
}
|
||||
currentEditorView = null
|
||||
}
|
||||
|
||||
provider?.disconnect()
|
||||
provider?.destroy()
|
||||
ydoc?.destroy()
|
||||
|
||||
provider = null
|
||||
ydoc = null
|
||||
ytext = null
|
||||
lastSyncState = null
|
||||
roomUserInfo.clear()
|
||||
hasShownSuperAdminLeftMessage = false
|
||||
}
|
||||
|
||||
function getInitialExtension() {
|
||||
return collabCompartment.of([])
|
||||
}
|
||||
|
||||
return {
|
||||
startSync,
|
||||
stopSync,
|
||||
getInitialExtension,
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,8 @@ export type LANGUAGE =
|
||||
| "JavaScript"
|
||||
| "Golang"
|
||||
|
||||
export type LANGUAGE_SHOW_LABEL = typeof LANGUAGE_SHOW_VALUE[keyof typeof LANGUAGE_SHOW_VALUE]
|
||||
export type LANGUAGE_SHOW_LABEL =
|
||||
(typeof LANGUAGE_SHOW_VALUE)[keyof typeof LANGUAGE_SHOW_VALUE]
|
||||
|
||||
export type SUBMISSION_RESULT = -2 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||
|
||||
|
||||
Reference in New Issue
Block a user