-
+
本道题你在班上排名第 {{ rank }},你们班共有 {{ class_ac_count }} 人答案正确
-
+
-
-
- 本道题你还没有解决,
-
你们班
- 共有
{{ class_ac_count }} 人答案正确
-
+
+
+ 本道题你还没有解决,你们班共有 {{ class_ac_count }} 人答案正确
+
-
+
本道题你在全服排名第 {{ rank }},全服共有 {{ all_ac_count }} 人答案正确
-
-
+
-
- 本道题你还没有解决,全服共有
- {{ all_ac_count }} 人答案正确
-
+
+ 本道题你还没有解决,全服共有 {{ all_ac_count }} 人答案正确
+
-
-
+
+
(), {
+ sync: false,
+ problem: "",
language: "Python3",
fontSize: 20,
height: "100%",
@@ -35,25 +35,105 @@ const props = withDefaults(defineProps(), {
placeholder: "",
})
+const { readonly, placeholder, height, fontSize } = toRefs(props)
const code = defineModel("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(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)
+
diff --git a/src/shared/components/IconButton.vue b/src/shared/components/IconButton.vue
index b672330..07897b2 100644
--- a/src/shared/components/IconButton.vue
+++ b/src/shared/components/IconButton.vue
@@ -1,7 +1,7 @@
-
+
@@ -16,6 +16,14 @@ import { Icon } from "@iconify/vue"
defineProps<{
tip: string
icon: string
+ type?:
+ | "default"
+ | "tertiary"
+ | "primary"
+ | "info"
+ | "success"
+ | "warning"
+ | "error"
}>()
defineEmits(["click"])
diff --git a/src/shared/components/TextCopy.vue b/src/shared/components/TextCopy.vue
new file mode 100644
index 0000000..4624728
--- /dev/null
+++ b/src/shared/components/TextCopy.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+ 点击复制
+
+
+
\ No newline at end of file
diff --git a/src/shared/composables/sync.ts b/src/shared/composables/sync.ts
new file mode 100644
index 0000000..094d400
--- /dev/null
+++ b/src/shared/composables/sync.ts
@@ -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()
+ 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): boolean => {
+ if (userStore.isSuperAdmin) return true
+ return Array.from(awarenessStates.values()).some(
+ (state) => state.user?.isSuperAdmin,
+ )
+ }
+
+ const getOtherUserInfo = (
+ awarenessStates: Map,
+ ): 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,
+ }
+}
diff --git a/src/utils/types.ts b/src/utils/types.ts
index 7cfb417..dd81b89 100644
--- a/src/utils/types.ts
+++ b/src/utils/types.ts
@@ -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