协同编辑代码

This commit is contained in:
2025-10-05 01:18:00 +08:00
parent fce96f2087
commit 7b139d404e
18 changed files with 1728 additions and 161 deletions

1
.env
View File

@@ -3,3 +3,4 @@ PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb
PUBLIC_OJ_URL=http://localhost:8000 PUBLIC_OJ_URL=http://localhost:8000
PUBLIC_CODE_URL=http://localhost:3000 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

View File

@@ -3,3 +3,4 @@ PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb
PUBLIC_OJ_URL=https://oj.xuyue.cc PUBLIC_OJ_URL=https://oj.xuyue.cc
PUBLIC_CODE_URL=https://code.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

View File

@@ -4,3 +4,4 @@ PUBLIC_OJ_URL=http://10.13.114.114:81
PUBLIC_CODE_URL=http://10.13.114.114:82 PUBLIC_CODE_URL=http://10.13.114.114:82
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082 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

View File

@@ -4,3 +4,4 @@ PUBLIC_OJ_URL=http://10.13.114.114:81
PUBLIC_CODE_URL=http://10.13.114.114:82 PUBLIC_CODE_URL=http://10.13.114.114:82
PUBLIC_JUDGE0_URL=http://10.13.114.114:8082 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
View 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 是一个 CRDTConflict-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
View File

@@ -29,7 +29,10 @@
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-chartjs": "^5.3.2", "vue-chartjs": "^5.3.2",
"vue-codemirror": "^6.1.1", "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": { "devDependencies": {
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.0",
@@ -466,6 +469,7 @@
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.2.tgz", "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@marijn/find-cluster-break": "^1.0.0" "@marijn/find-cluster-break": "^1.0.0"
} }
@@ -475,6 +479,7 @@
"resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.38.4.tgz", "resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.38.4.tgz",
"integrity": "sha512-hduz0suCcUSC/kM8Fq3A9iLwInJDl8fD1xLpTIk+5xkNm8z/FT7UsIa9sOXrkpChh+XXc18RzswE8QqELsVl+g==", "integrity": "sha512-hduz0suCcUSC/kM8Fq3A9iLwInJDl8fD1xLpTIk+5xkNm8z/FT7UsIa9sOXrkpChh+XXc18RzswE8QqELsVl+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
"crelt": "^1.0.6", "crelt": "^1.0.6",
@@ -2041,6 +2046,26 @@
"proxy-from-env": "^1.1.0" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.8.11", "version": "2.8.11",
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.11.tgz", "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": "^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": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -2415,7 +2464,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -2495,6 +2543,12 @@
"url": "https://github.com/fb55/entities?sponsor=1" "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": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", "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" "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": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -3025,6 +3085,26 @@
"@babel/runtime": "^7.23.2" "@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": { "node_modules/immer": {
"version": "9.0.21", "version": "9.0.21",
"resolved": "https://registry.npmmirror.com/immer/-/immer-9.0.21.tgz", "resolved": "https://registry.npmmirror.com/immer/-/immer-9.0.21.tgz",
@@ -3035,6 +3115,12 @@
"url": "https://opencollective.com/immer" "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": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", "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" "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": { "node_modules/jest-worker": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmmirror.com/jest-worker/-/jest-worker-27.5.1.tgz", "resolved": "https://registry.npmmirror.com/jest-worker/-/jest-worker-27.5.1.tgz",
@@ -3177,6 +3273,27 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/linkify-it": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz", "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz",
@@ -3469,7 +3586,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/naive-ui": { "node_modules/naive-ui": {
@@ -3751,16 +3867,49 @@
], ],
"license": "MIT" "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": { "node_modules/randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmmirror.com/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmmirror.com/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safe-buffer": "^5.1.0" "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": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
@@ -3807,7 +3956,6 @@
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -3876,6 +4024,35 @@
"randombytes": "^2.1.0" "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": { "node_modules/slate": {
"version": "0.82.1", "version": "0.82.1",
"resolved": "https://registry.npmmirror.com/slate/-/slate-0.82.1.tgz", "resolved": "https://registry.npmmirror.com/slate/-/slate-0.82.1.tgz",
@@ -3955,6 +4132,15 @@
"integrity": "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==", "integrity": "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==",
"license": "MIT" "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": { "node_modules/strip-literal": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz", "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz",
@@ -4316,6 +4502,12 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/vdirs": {
"version": "0.1.8", "version": "0.1.8",
"resolved": "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz", "resolved": "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz",
@@ -4544,6 +4736,28 @@
"integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==", "integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==",
"license": "MIT" "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": { "node_modules/xss": {
"version": "1.0.15", "version": "1.0.15",
"resolved": "https://registry.npmmirror.com/xss/-/xss-1.0.15.tgz", "resolved": "https://registry.npmmirror.com/xss/-/xss-1.0.15.tgz",
@@ -4559,6 +4773,89 @@
"engines": { "engines": {
"node": ">= 0.10.0" "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"
}
} }
} }
} }

View File

@@ -31,7 +31,10 @@
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-chartjs": "^5.3.2", "vue-chartjs": "^5.3.2",
"vue-codemirror": "^6.1.1", "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": { "devDependencies": {
"@iconify/vue": "^5.0.0", "@iconify/vue": "^5.0.0",

View File

@@ -2,6 +2,7 @@
import { PROBLEM_PERMISSION, USER_TYPE } from "~/utils/constants" import { PROBLEM_PERMISSION, USER_TYPE } from "~/utils/constants"
import { getUserRole } from "~/utils/functions" import { getUserRole } from "~/utils/functions"
import { User } from "~/utils/types" import { User } from "~/utils/types"
import TextCopy from "~/shared/components/TextCopy.vue"
interface Props { interface Props {
user: User user: User
@@ -30,6 +31,6 @@ const isNotRegularUser = computed(
: "仅自己" : "仅自己"
}} }}
</n-tag> </n-tag>
{{ props.user.username }} <TextCopy>{{ props.user.username }}</TextCopy>
</n-flex> </n-flex>
</template> </template>

View File

@@ -14,6 +14,8 @@ import {
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
import Name from "./components/Name.vue" import Name from "./components/Name.vue"
import { PROBLEM_PERMISSION, USER_TYPE } from "~/utils/constants" import { PROBLEM_PERMISSION, USER_TYPE } from "~/utils/constants"
import { useRouteQuery } from "@vueuse/router"
import TextCopy from "~/shared/components/TextCopy.vue"
const message = useMessage() const message = useMessage()
@@ -23,9 +25,9 @@ interface UserQuery {
} }
// 使用分页 composable // 使用分页 composable
const { query, clearQuery } = usePagination<UserQuery>({ const { query } = usePagination<UserQuery>({
keyword: "", keyword: useRouteQuery("keyword", "").value,
type: "", type: useRouteQuery("type", "").value,
}) })
const total = ref(0) const total = ref(0)
@@ -56,6 +58,7 @@ const columns: DataTableColumn<User>[] = [
title: "密码", title: "密码",
key: "raw_password", key: "raw_password",
width: 100, width: 100,
render: (row) => h(TextCopy, () => row.raw_password),
}, },
{ {
title: "创建时间", title: "创建时间",
@@ -72,7 +75,7 @@ const columns: DataTableColumn<User>[] = [
? parseTime(row.last_login, "YYYY-MM-DD HH:mm:ss") ? 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 }, { title: "邮箱", key: "email", width: 200 },
{ {
key: "actions", key: "actions",

1
src/env.d.ts vendored
View File

@@ -7,6 +7,7 @@ interface ImportMetaEnv {
readonly PUBLIC_CODE_URL: string readonly PUBLIC_CODE_URL: string
readonly PUBLIC_JUDGE0_URL: string readonly PUBLIC_JUDGE0_URL: string
readonly PUBLIC_ICONIFY_URL: string readonly PUBLIC_ICONIFY_URL: string
readonly PUBLIC_SIGNALING_URL: string
} }
interface ImportMeta { interface ImportMeta {

View File

@@ -5,56 +5,90 @@ import { SOURCES } from "utils/constants"
import CodeEditor from "~/shared/components/CodeEditor.vue" import CodeEditor from "~/shared/components/CodeEditor.vue"
import { isDesktop } from "~/shared/composables/breakpoints" import { isDesktop } from "~/shared/composables/breakpoints"
import storage from "~/utils/storage" import storage from "~/utils/storage"
import { LANGUAGE } from "~/utils/types"
import Form from "./Form.vue" import Form from "./Form.vue"
const route = useRoute() 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( const storageKey = computed(
() => () =>
`problem_${problem.value!._id}_contest_${contestID}_lang_${code.language}`, `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(() => const editorHeight = computed(() =>
isDesktop.value ? "calc(100vh - 133px)" : "calc(100vh - 172px)", 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) storage.set(storageKey.value, v)
} }
function changeLanguage(v: string) { const changeLanguage = (v: LANGUAGE) => {
if ( const savedCode = storage.get(storageKey.value)
storage.get(storageKey.value) &&
storageKey.value.split("_").pop() === v
) {
code.value = storage.get(storageKey.value)
} else {
code.value = code.value =
problem.value!.template[code.language] || SOURCES[code.language] 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> </script>
<template> <template>
<n-flex vertical> <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 <CodeEditor
v-model:value="code.value" v-model:value="code.value"
@update:model-value="changeCode" :sync="sync"
:problem="problem!._id"
:language="code.language" :language="code.language"
:height="editorHeight" :height="editorHeight"
@update:model-value="changeCode"
@sync-closed="handleSyncClosed"
@sync-status-change="handleSyncStatusChange"
/> />
</n-flex> </n-flex>
</template> </template>
<style scoped></style>

View File

@@ -15,151 +15,187 @@ import IconButton from "~/shared/components/IconButton.vue"
interface Props { interface Props {
storageKey: string storageKey: string
withTest?: boolean withTest?: boolean
otherUserInfo?: { name: string; isSuperAdmin: boolean }
isSynced?: boolean
hadConnection?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
withTest: false, withTest: false,
isSynced: false,
hadConnection: false,
}) })
const emit = defineEmits<{
changeLanguage: [v: LANGUAGE]
toggleSync: [v: boolean]
}>()
const message = useMessage() const message = useMessage()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const emit = defineEmits(["changeLanguage"]) const isSynced = ref(false)
const statisticPanel = 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[]>(() => [ const menu = computed<DropdownOption[]>(() => [
{ label: "去自测猫", key: "test", show: isMobile.value }, { label: "去自测猫", key: "test", show: isMobile.value },
{ label: "复制代码", key: "copy" }, { label: "复制代码", key: "copy" },
{ label: "重置代码", key: "reset" }, { label: "重置代码", key: "reset" },
]) ])
const options: DropdownOption[] = problem.value!.languages.map((it) => ({ const languageOptions: DropdownOption[] = problem.value!.languages.map((it) => ({
label: () => [ label: () =>
h("div", { style: "display: flex; align-items: center;" }, [
h("img", { h("img", {
src: `/${it}.svg`, src: `/${it}.svg`,
style: { style: { width: "16px", height: "16px", marginRight: "8px" },
width: "16px",
height: "16px",
marginRight: "8px",
transform: "translateY(3px)",
},
}), }),
LANGUAGE_SHOW_VALUE[it], LANGUAGE_SHOW_VALUE[it],
], ]),
value: it, value: it,
})) }))
async function select(key: string) { const copy = () => {
switch (key) { copyText(code.value)
case "reset": message.success("代码复制成功")
reset()
break
case "copy":
copy()
break
case "test":
window.open(import.meta.env.PUBLIC_CODE_URL, "_blank")
break
}
} }
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) storage.set(STORAGE_KEY.LANGUAGE, v)
emit("changeLanguage", v) emit("changeLanguage", v)
} }
function gotoTestCat() { const toggleSync = () => {
const url = import.meta.env.PUBLIC_CODE_URL isSynced.value = !isSynced.value
window.open(url, "_blank") emit("toggleSync", isSynced.value)
} }
function showStatisticsPanel() { const gotoTestCat = () => {
statisticPanel.value = true window.open(import.meta.env.PUBLIC_CODE_URL, "_blank")
} }
defineExpose({
resetSyncStatus: () => {
isSynced.value = false
},
})
</script> </script>
<template> <template>
<n-flex align="center"> <n-flex align="center">
<n-select <n-select
class="language"
v-model:value="code.language" v-model:value="code.language"
class="language"
:size="isDesktop ? 'medium' : 'small'"
:options="languageOptions"
@update:value="changeLanguage" @update:value="changeLanguage"
:size="isDesktop ? 'medium' : 'small'"
:options="options"
/> />
<n-button v-if="withTest" @click="reset">重置代码</n-button>
<n-button v-if="withTest" type="primary" secondary @click="test"> <template v-if="withTest">
运行代码 <n-button @click="reset">重置代码</n-button>
</n-button> <n-button type="primary" secondary @click="test">运行代码</n-button>
<n-flex align="center" v-if="!withTest"> </template>
<n-flex v-else align="center">
<Submit /> <Submit />
<n-button v-if="isDesktop" @click="gotoTestCat">自测猫</n-button>
<n-button <n-button
:size="isDesktop ? 'medium' : 'small'"
v-if="!userStore.isSuperAdmin && userStore.showSubmissions" v-if="!userStore.isSuperAdmin && userStore.showSubmissions"
:size="isDesktop ? 'medium' : 'small'"
@click="goSubmissions" @click="goSubmissions"
> >
提交信息 提交信息
</n-button> </n-button>
<n-button <n-button
:size="isDesktop ? 'medium' : 'small'"
v-if="userStore.isSuperAdmin" v-if="userStore.isSuperAdmin"
@click="showStatisticsPanel" :size="isDesktop ? 'medium' : 'small'"
@click="statisticPanel = true"
> >
{{ isDesktop ? "统计信息" : "统计" }} {{ isDesktop ? "统计信息" : "统计" }}
</n-button> </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-button :size="isDesktop ? 'medium' : 'small'">操作</n-button>
</n-dropdown> </n-dropdown>
<IconButton <IconButton
v-if="isDesktop && userStore.isSuperAdmin"
icon="streamline-ultimate-color:file-code-edit" icon="streamline-ultimate-color:file-code-edit"
tip="编辑题目" tip="编辑题目"
v-if="isDesktop && userStore.isSuperAdmin"
@click="goEdit" @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-flex> </n-flex>
<n-modal <n-modal
v-if="userStore.isSuperAdmin" v-if="userStore.isSuperAdmin"
v-model:show="statisticPanel" v-model:show="statisticPanel"
preset="card" preset="card"
title="提交记录的统计"
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }" :style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }" :content-style="{ overflow: 'auto' }"
title="提交记录的统计"
> >
<StatisticsPanel :problem="problem!._id" username="" /> <StatisticsPanel :problem="problem!._id" username="" />
</n-modal> </n-modal>

View File

@@ -3,14 +3,12 @@ import { NButton } from "naive-ui"
import { getSubmissions, getRankOfProblem } from "~/oj/api" import { getSubmissions, getRankOfProblem } from "~/oj/api"
import Pagination from "~/shared/components/Pagination.vue" import Pagination from "~/shared/components/Pagination.vue"
import SubmissionResultTag from "~/shared/components/SubmissionResultTag.vue" import SubmissionResultTag from "~/shared/components/SubmissionResultTag.vue"
import { useConfigStore } from "~/shared/store/config"
import { useUserStore } from "~/shared/store/user" import { useUserStore } from "~/shared/store/user"
import { LANGUAGE_SHOW_VALUE } from "~/utils/constants" import { LANGUAGE_SHOW_VALUE } from "~/utils/constants"
import { parseTime } from "~/utils/functions" import { parseTime } from "~/utils/functions"
import { renderTableTitle } from "~/utils/renders" import { renderTableTitle } from "~/utils/renders"
import { Submission } from "~/utils/types" import { Submission } from "~/utils/types"
const configStore = useConfigStore()
const userStore = useUserStore() const userStore = useUserStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -117,7 +115,7 @@ watch(query, listSubmissions)
<n-alert <n-alert
class="tip" class="tip"
type="error" type="error"
v-if="!userStore.showSubmissions" v-if="!userStore.showSubmissions || !userStore.isAuthed"
:title="errorMsg" :title="errorMsg"
/> />
@@ -126,11 +124,12 @@ watch(query, listSubmissions)
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1"> <n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
<template #header> <template #header>
<n-flex align="center"> <n-flex align="center">
<div> <span>
本道题你在班上排名第 <b>{{ rank }}</b 本道题你在班上排名第 <b>{{ rank }}</b
>你们班共有 <b>{{ class_ac_count }}</b> 人答案正确 >你们班共有 <b>{{ class_ac_count }}</b> 人答案正确
</div> </span>
<n-button <n-button
secondary
v-if="userStore.showSubmissions" v-if="userStore.showSubmissions"
@click=" @click="
router.push({ router.push({
@@ -157,12 +156,10 @@ watch(query, listSubmissions)
v-if="rank === -1 && class_ac_count > 0" v-if="rank === -1 && class_ac_count > 0"
> >
<template #header> <template #header>
<n-flex> <n-flex align="center">
<div> <span>
本道题你还没有解决 本道题你还没有解决你们班共有 <b>{{ class_ac_count }}</b> 人答案正确
<div v-if="class_name">你们班</div> </span>
共有 <b>{{ class_ac_count }}</b> 人答案正确
</div>
<n-button <n-button
v-if="userStore.showSubmissions" v-if="userStore.showSubmissions"
secondary secondary
@@ -189,11 +186,10 @@ watch(query, listSubmissions)
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1"> <n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
<template #header> <template #header>
<n-flex align="center"> <n-flex align="center">
<div> <span>
本道题你在全服排名第 <b>{{ rank }}</b 本道题你在全服排名第 <b>{{ rank }}</b
>全服共有 <b>{{ all_ac_count }}</b> 人答案正确 >全服共有 <b>{{ all_ac_count }}</b> 人答案正确
</div> </span>
<div></div>
<n-button <n-button
secondary secondary
v-if="userStore.showSubmissions" v-if="userStore.showSubmissions"
@@ -222,10 +218,9 @@ watch(query, listSubmissions)
> >
<template #header> <template #header>
<n-flex align="center"> <n-flex align="center">
<div> <span>
本道题你还没有解决全服共有 本道题你还没有解决全服共有 <b>{{ all_ac_count }}</b> 人答案正确
<b>{{ all_ac_count }}</b> 人答案正确 </span>
</div>
<n-button <n-button
v-if="userStore.showSubmissions" v-if="userStore.showSubmissions"
secondary secondary
@@ -249,8 +244,8 @@ watch(query, listSubmissions)
</template> </template>
</template> </template>
<template v-if="userStore.showSubmissions"> <template v-if="userStore.showSubmissions && userStore.isAuthed">
<n-data-table striped :columns="columns" :data="submissions" /> <n-data-table v-if="submissions.length > 0" striped :columns="columns" :data="submissions" />
<Pagination <Pagination
:total="total" :total="total"
v-model:limit="query.limit" v-model:limit="query.limit"

View File

@@ -3,23 +3,21 @@ import { cpp } from "@codemirror/lang-cpp"
import { python } from "@codemirror/lang-python" import { python } from "@codemirror/lang-python"
import { EditorView } from "@codemirror/view" import { EditorView } from "@codemirror/view"
import { Codemirror } from "vue-codemirror" import { Codemirror } from "vue-codemirror"
import type { Extension } from "@codemirror/state"
import { LANGUAGE } from "~/utils/types" import { LANGUAGE } from "~/utils/types"
import { oneDark } from "../themes/oneDark" import { oneDark } from "../themes/oneDark"
import { smoothy } from "../themes/smoothy" import { smoothy } from "../themes/smoothy"
import { useCodeSync } from "../composables/sync"
const styleTheme = EditorView.baseTheme({ interface EditorReadyPayload {
"& .cm-scroller": { view: EditorView
"font-family": "Monaco", state: any
}, container: HTMLElement
"&.cm-editor.cm-focused": { }
outline: "none",
},
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
"font-family": "Monaco",
},
})
interface Props { interface Props {
sync?: boolean
problem?: string
language?: LANGUAGE language?: LANGUAGE
fontSize?: number fontSize?: number
height?: string height?: string
@@ -28,6 +26,8 @@ interface Props {
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
sync: false,
problem: "",
language: "Python3", language: "Python3",
fontSize: 20, fontSize: 20,
height: "100%", height: "100%",
@@ -35,25 +35,105 @@ const props = withDefaults(defineProps<Props>(), {
placeholder: "", placeholder: "",
}) })
const { readonly, placeholder, height, fontSize } = toRefs(props)
const code = defineModel<string>("value") const code = defineModel<string>("value")
const emit = defineEmits<{
syncClosed: []
syncStatusChange: [
status: { otherUser?: { name: string; isSuperAdmin: boolean } },
]
}>()
const isDark = useDark() const isDark = useDark()
const lang = computed(() => { const styleTheme = EditorView.baseTheme({
if (["Python2", "Python3"].includes(props.language)) { "& .cm-scroller": { "font-family": "Monaco" },
return python() "&.cm-editor.cm-focused": { outline: "none" },
} "&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
return cpp() "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> </script>
<template> <template>
<Codemirror <Codemirror
v-model="code" v-model="code"
indentWithTab indentWithTab
:extensions="[styleTheme, lang, isDark ? oneDark : smoothy]" :extensions="extensions"
:disabled="props.readonly" :disabled="readonly"
:tabSize="4" :tab-size="4"
:placeholder="props.placeholder" :placeholder="placeholder"
:style="{ height: props.height, fontSize: props.fontSize + 'px' }" :style="{ height, fontSize: `${fontSize}px` }"
@ready="handleEditorReady"
/> />
</template> </template>

View File

@@ -1,7 +1,7 @@
<template> <template>
<n-tooltip> <n-tooltip>
<template #trigger> <template #trigger>
<n-button circle @click="$emit('click')"> <n-button circle :type="type ?? 'default'" @click="$emit('click')">
<template #icon> <template #icon>
<Icon :icon="icon" /> <Icon :icon="icon" />
</template> </template>
@@ -16,6 +16,14 @@ import { Icon } from "@iconify/vue"
defineProps<{ defineProps<{
tip: string tip: string
icon: string icon: string
type?:
| "default"
| "tertiary"
| "primary"
| "info"
| "success"
| "warning"
| "error"
}>() }>()
defineEmits(["click"]) defineEmits(["click"])
</script> </script>

View 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>

View 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,
}
}

View File

@@ -60,7 +60,8 @@ export type LANGUAGE =
| "JavaScript" | "JavaScript"
| "Golang" | "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 export type SUBMISSION_RESULT = -2 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9