feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码
This commit is contained in:
20
apps/web/src/shared/composables/breakpoints.ts
Normal file
20
apps/web/src/shared/composables/breakpoints.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
breakpointsTailwind,
|
||||
useBreakpoints as useVueUseBreakpoints,
|
||||
} from "@vueuse/core"
|
||||
|
||||
/**
|
||||
* 响应式断点检测 composable
|
||||
* 每次调用创建新的断点检测实例
|
||||
*/
|
||||
export function useBreakpoints() {
|
||||
const breakpoints = useVueUseBreakpoints(breakpointsTailwind)
|
||||
|
||||
const isMobile = breakpoints.smallerOrEqual("md")
|
||||
const isDesktop = breakpoints.greater("md")
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
isDesktop,
|
||||
}
|
||||
}
|
||||
41
apps/web/src/shared/composables/configUpdate.ts
Normal file
41
apps/web/src/shared/composables/configUpdate.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import {
|
||||
useConfigWebSocket,
|
||||
type ConfigUpdate,
|
||||
} from "shared/composables/websocket"
|
||||
|
||||
export function useConfigUpdate() {
|
||||
const configStore = useConfigStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 处理 WebSocket 配置更新
|
||||
const handleConfigUpdate = (data: ConfigUpdate) => {
|
||||
// 更新全局配置 - 使用响应式方式
|
||||
if (data.key in configStore.config) {
|
||||
// 直接修改 ref 的值来触发响应式更新
|
||||
;(configStore.config as any)[data.key] = data.value
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 WebSocket - handler 会在 onMounted 时自动添加
|
||||
const { connect, disconnect } = useConfigWebSocket(handleConfigUpdate)
|
||||
|
||||
// 监听登录状态变化
|
||||
watch(
|
||||
() => userStore.isAuthed,
|
||||
(isAuthed) => {
|
||||
if (isAuthed) {
|
||||
connect()
|
||||
} else {
|
||||
disconnect()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
}
|
||||
}
|
||||
15
apps/web/src/shared/composables/learnProgress.ts
Normal file
15
apps/web/src/shared/composables/learnProgress.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useStorage } from "@vueuse/core"
|
||||
import { STORAGE_KEY } from "utils/constants"
|
||||
|
||||
export type TutorialType = "python" | "c"
|
||||
|
||||
// 模块级单例:学习页写入、导航栏读取,必须共用同一个响应式引用,
|
||||
// 否则导航栏的链接不会随学习进度更新
|
||||
const learnStep = useStorage<Record<TutorialType, number>>(
|
||||
STORAGE_KEY.LEARN_CURRENT_STEP,
|
||||
{ python: 1, c: 1 },
|
||||
)
|
||||
|
||||
export function useLearnProgress() {
|
||||
return { learnStep }
|
||||
}
|
||||
132
apps/web/src/shared/composables/maxkb.ts
Normal file
132
apps/web/src/shared/composables/maxkb.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import {
|
||||
useConfigWebSocket,
|
||||
type ConfigUpdate,
|
||||
} from "shared/composables/websocket"
|
||||
|
||||
export function useMaxKB() {
|
||||
const configStore = useConfigStore()
|
||||
const userStore = useUserStore()
|
||||
const isLoaded = ref(false)
|
||||
|
||||
// 处理 WebSocket 配置更新 - 只处理 MaxKB 相关
|
||||
const handleConfigUpdate = (data: ConfigUpdate) => {
|
||||
if (data.key === "enable_maxkb") {
|
||||
if (data.value) {
|
||||
loadMaxKBScript()
|
||||
} else {
|
||||
removeMaxKBScript()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 WebSocket
|
||||
const { connect, disconnect } = useConfigWebSocket(handleConfigUpdate)
|
||||
|
||||
// 监听登录状态变化
|
||||
watch(
|
||||
() => userStore.isAuthed,
|
||||
(isAuthed) => {
|
||||
if (isAuthed) {
|
||||
connect()
|
||||
} else {
|
||||
disconnect()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const loadMaxKBScript = () => {
|
||||
const { enable_maxkb } = configStore.config
|
||||
|
||||
if (!enable_maxkb) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector(
|
||||
`script[src="${import.meta.env.PUBLIC_MAXKB_URL}"]`,
|
||||
)
|
||||
if (existingScript) {
|
||||
isLoaded.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 创建并插入脚本标签
|
||||
const script = document.createElement("script")
|
||||
script.src = import.meta.env.PUBLIC_MAXKB_URL
|
||||
script.async = true
|
||||
script.defer = true
|
||||
|
||||
script.onload = () => {
|
||||
isLoaded.value = true
|
||||
}
|
||||
|
||||
script.onerror = () => {
|
||||
console.error("Failed to load MaxKB script")
|
||||
}
|
||||
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
const removeMaxKBScript = () => {
|
||||
// 把 script 也删除
|
||||
const script = document.querySelector(
|
||||
`script[src="${import.meta.env.PUBLIC_MAXKB_URL}"]`,
|
||||
)
|
||||
if (script) {
|
||||
script.remove()
|
||||
}
|
||||
// 等待DOM加载完成后删除所有id以"maxkb-"开头的元素
|
||||
const removeMaxKBElements = () => {
|
||||
// 查找所有id以"maxkb-"开头的元素
|
||||
const elements = document.querySelectorAll('[id^="maxkb-"]')
|
||||
|
||||
elements.forEach((element) => {
|
||||
element.remove()
|
||||
})
|
||||
}
|
||||
|
||||
// 如果DOM已经加载完成,直接执行删除
|
||||
if (document.readyState === "complete") {
|
||||
removeMaxKBElements()
|
||||
} else {
|
||||
// 等待DOM加载完成
|
||||
window.addEventListener("load", removeMaxKBElements, { once: true })
|
||||
}
|
||||
|
||||
// 移除MaxKB脚本标签
|
||||
const existingScript = document.querySelector(
|
||||
`script[src="${import.meta.env.PUBLIC_MAXKB_URL}"]`,
|
||||
)
|
||||
if (existingScript) {
|
||||
existingScript.remove()
|
||||
}
|
||||
|
||||
// 重置加载状态
|
||||
isLoaded.value = false
|
||||
}
|
||||
|
||||
// 连接 WebSocket
|
||||
onMounted(() => {
|
||||
connect()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => configStore.config.enable_maxkb,
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
loadMaxKBScript()
|
||||
} else {
|
||||
removeMaxKBScript()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
loadMaxKBScript,
|
||||
removeMaxKBScript,
|
||||
isLoaded: readonly(isLoaded),
|
||||
}
|
||||
}
|
||||
154
apps/web/src/shared/composables/pagination.ts
Normal file
154
apps/web/src/shared/composables/pagination.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { reactive, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { filterEmptyValue } from "utils/functions"
|
||||
|
||||
export interface PaginationQuery {
|
||||
page: number
|
||||
limit: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface UsePaginationOptions {
|
||||
/** 默认每页条数 */
|
||||
defaultLimit?: number
|
||||
/** 默认页码 */
|
||||
defaultPage?: number
|
||||
/** 当其他查询条件变化时是否重置页码 */
|
||||
resetPageOnChange?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页相关的 composable,处理分页状态和 URL 同步
|
||||
* 每次调用创建新的分页状态实例
|
||||
* @param initialQuery 初始查询参数对象
|
||||
* @param options 配置选项
|
||||
*/
|
||||
export function usePagination<T extends Record<string, any>>(
|
||||
initialQuery: Omit<T, "page" | "limit"> = {} as Omit<T, "page" | "limit">,
|
||||
options: UsePaginationOptions = {},
|
||||
) {
|
||||
const {
|
||||
defaultLimit = 10,
|
||||
defaultPage = 1,
|
||||
resetPageOnChange = true,
|
||||
} = options
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 从 URL 查询参数初始化状态
|
||||
const query = reactive({
|
||||
page: parseInt(<string>route.query.page) || defaultPage,
|
||||
limit: parseInt(<string>route.query.limit) || defaultLimit,
|
||||
...initialQuery,
|
||||
}) as unknown as T & PaginationQuery
|
||||
|
||||
// 同步 URL 查询参数到本地状态
|
||||
function syncFromRoute() {
|
||||
;(query as any).page = parseInt(<string>route.query.page) || defaultPage
|
||||
;(query as any).limit = parseInt(<string>route.query.limit) || defaultLimit
|
||||
|
||||
// 同步其他查询参数
|
||||
Object.keys(initialQuery).forEach((key) => {
|
||||
const value = route.query[key]
|
||||
if (value !== undefined) {
|
||||
// 处理不同类型的参数
|
||||
if (typeof initialQuery[key] === "boolean") {
|
||||
;(query as any)[key] = value === "1" || value === "true"
|
||||
} else if (typeof initialQuery[key] === "number") {
|
||||
;(query as any)[key] = parseInt(<string>value) || initialQuery[key]
|
||||
} else {
|
||||
;(query as any)[key] = <string>value || initialQuery[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 更新 URL
|
||||
function updateRoute() {
|
||||
const newQuery = filterEmptyValue(query)
|
||||
router.push({
|
||||
path: route.path,
|
||||
query: newQuery,
|
||||
})
|
||||
}
|
||||
|
||||
// 重置页码到第一页
|
||||
function resetPage() {
|
||||
;(query as any).page = defaultPage
|
||||
}
|
||||
|
||||
// 清空所有查询条件(除了分页参数)
|
||||
function clearQuery() {
|
||||
Object.keys(initialQuery).forEach((key) => {
|
||||
const initialValue = initialQuery[key]
|
||||
if (typeof initialValue === "string") {
|
||||
;(query as any)[key] = ""
|
||||
} else if (typeof initialValue === "boolean") {
|
||||
;(query as any)[key] = false
|
||||
} else if (typeof initialValue === "number") {
|
||||
;(query as any)[key] = 0
|
||||
} else {
|
||||
;(query as any)[key] = initialValue
|
||||
}
|
||||
})
|
||||
resetPage()
|
||||
}
|
||||
|
||||
// 监听页码变化,同步到 URL
|
||||
watch(() => query.page, updateRoute)
|
||||
|
||||
// 监听每页条数变化,重置页码并同步到 URL
|
||||
watch(
|
||||
() => query.limit,
|
||||
() => {
|
||||
if (resetPageOnChange) {
|
||||
resetPage()
|
||||
}
|
||||
updateRoute()
|
||||
},
|
||||
)
|
||||
|
||||
// 监听其他查询条件变化,重置页码并同步到 URL
|
||||
if (resetPageOnChange && Object.keys(initialQuery).length > 0) {
|
||||
const otherQueryKeys = Object.keys(initialQuery)
|
||||
watch(
|
||||
() => otherQueryKeys.map((key) => query[key]),
|
||||
() => {
|
||||
resetPage()
|
||||
updateRoute()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
}
|
||||
|
||||
// 监听路由变化,同步到本地状态
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
syncFromRoute()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
return {
|
||||
query,
|
||||
updateRoute,
|
||||
resetPage,
|
||||
clearQuery,
|
||||
syncFromRoute,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化版本的分页 composable,只处理基本的分页逻辑
|
||||
* 每次调用创建新的分页状态实例
|
||||
* @param defaultLimit 默认每页条数
|
||||
* @param defaultPage 默认页码
|
||||
*/
|
||||
export function useSimplePagination(defaultLimit = 10, defaultPage = 1) {
|
||||
return usePagination(
|
||||
{},
|
||||
{ defaultLimit, defaultPage, resetPageOnChange: false },
|
||||
)
|
||||
}
|
||||
15
apps/web/src/shared/composables/rarity.ts
Normal file
15
apps/web/src/shared/composables/rarity.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useDark } from "@vueuse/core"
|
||||
import { computed } from "vue"
|
||||
import { RARITY_TEXT_COLOR } from "utils/constants"
|
||||
|
||||
/**
|
||||
* 成就稀有度的文字配色,跟随明暗主题切换。
|
||||
* 两套色值都压到 4.5:1 以上,不然浅色模式下白金和黄金的小字看不清。
|
||||
* 边框和色块不用这个,直接用 RARITY_COLOR 的原色。
|
||||
*/
|
||||
export function useRarityColor() {
|
||||
const isDark = useDark()
|
||||
return computed(() =>
|
||||
isDark.value ? RARITY_TEXT_COLOR.dark : RARITY_TEXT_COLOR.light,
|
||||
)
|
||||
}
|
||||
412
apps/web/src/shared/composables/sync.ts
Normal file
412
apps/web/src/shared/composables/sync.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { useMessage } from "naive-ui"
|
||||
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
|
||||
|
||||
// 错误类型码
|
||||
export const SYNC_ERROR_CODES = {
|
||||
SUPER_ADMIN_LEFT: "SUPER_ADMIN_LEFT",
|
||||
MISSING_SUPER_ADMIN: "MISSING_SUPER_ADMIN",
|
||||
} as const
|
||||
|
||||
// 界面和通知文案
|
||||
export const SYNC_MESSAGES = {
|
||||
// 超管离开
|
||||
SUPER_ADMIN_LEFT: (name: string) => `超管 ${name} 已离开`,
|
||||
|
||||
// 缺少超管
|
||||
MISSING_SUPER_ADMIN: "协同编辑需要超管",
|
||||
|
||||
// 连接成功
|
||||
SYNC_ACTIVE: "协同编辑已激活!",
|
||||
|
||||
// 连接断开
|
||||
CONNECTION_LOST: "协同编辑已断开",
|
||||
|
||||
// 等待相关
|
||||
WAITING_STUDENT: "正在等待学生加入...",
|
||||
WAITING_ADMIN: "正在等待超管加入...",
|
||||
|
||||
// Form.vue 界面文案
|
||||
SYNC_ON: "断开同步",
|
||||
SYNC_OFF: "开启同步",
|
||||
SYNCING_WITH: (name: string) => `与 ${name} 同步中`,
|
||||
STUDENT_LEFT: (name?: string) => (name ? `${name}已离开` : "可以关闭同步"),
|
||||
} as const
|
||||
|
||||
// 类型定义
|
||||
type SyncState = "waiting" | "active" | "error"
|
||||
type SyncErrorCode = (typeof SYNC_ERROR_CODES)[keyof typeof SYNC_ERROR_CODES]
|
||||
|
||||
interface UserInfo {
|
||||
name: string
|
||||
isSuperAdmin: boolean
|
||||
}
|
||||
|
||||
interface PeersEvent {
|
||||
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
|
||||
errorCode?: SyncErrorCode
|
||||
otherUser?: UserInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码同步 composable
|
||||
* 每次调用创建新的同步实例
|
||||
*/
|
||||
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
|
||||
const leftMessage = SYNC_MESSAGES.SUPER_ADMIN_LEFT(superAdminInfo.name)
|
||||
updateStatus(
|
||||
{
|
||||
connected: false,
|
||||
roomUsers: 0,
|
||||
canSync: false,
|
||||
message: leftMessage,
|
||||
error: leftMessage,
|
||||
errorCode: SYNC_ERROR_CODES.SUPER_ADMIN_LEFT,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
message.warning(leftMessage)
|
||||
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) {
|
||||
if (lastSyncState === "error") return
|
||||
|
||||
updateStatus(
|
||||
{
|
||||
connected: false,
|
||||
roomUsers,
|
||||
canSync: false,
|
||||
message: SYNC_MESSAGES.MISSING_SUPER_ADMIN,
|
||||
error: SYNC_MESSAGES.MISSING_SUPER_ADMIN,
|
||||
errorCode: SYNC_ERROR_CODES.MISSING_SUPER_ADMIN,
|
||||
otherUser,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
message.error(SYNC_MESSAGES.MISSING_SUPER_ADMIN)
|
||||
lastSyncState = "error"
|
||||
stopSync()
|
||||
return
|
||||
} else if (canSync) {
|
||||
updateStatus(
|
||||
{
|
||||
connected: true,
|
||||
roomUsers,
|
||||
canSync: true,
|
||||
message: SYNC_MESSAGES.SYNC_ACTIVE,
|
||||
otherUser,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
if (lastSyncState !== "active") {
|
||||
message.success(SYNC_MESSAGES.SYNC_ACTIVE)
|
||||
lastSyncState = "active"
|
||||
}
|
||||
} else {
|
||||
updateStatus(
|
||||
{
|
||||
connected: true,
|
||||
roomUsers,
|
||||
canSync: false,
|
||||
message:
|
||||
roomUsers === 1
|
||||
? SYNC_MESSAGES.WAITING_STUDENT
|
||||
: SYNC_MESSAGES.WAITING_ADMIN,
|
||||
otherUser,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
lastSyncState = "waiting"
|
||||
}
|
||||
}
|
||||
|
||||
const setupContentSync = (
|
||||
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) {
|
||||
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: SYNC_MESSAGES.CONNECTION_LOST,
|
||||
error: SYNC_MESSAGES.CONNECTION_LOST,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
message.warning(SYNC_MESSAGES.CONNECTION_LOST)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听用户加入/离开
|
||||
provider.on("peers", (event: PeersEvent) => {
|
||||
const roomUsers = event.webrtcPeers.length + 1
|
||||
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(ytext, provider, savedContent)
|
||||
|
||||
// 设置初始状态
|
||||
const waitingMessage = userStore.isSuperAdmin
|
||||
? SYNC_MESSAGES.WAITING_STUDENT
|
||||
: SYNC_MESSAGES.WAITING_ADMIN
|
||||
|
||||
updateStatus(
|
||||
{
|
||||
connected: true,
|
||||
roomUsers: 1,
|
||||
canSync: false,
|
||||
message: waitingMessage,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
|
||||
message.info(waitingMessage)
|
||||
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,
|
||||
}
|
||||
}
|
||||
334
apps/web/src/shared/composables/useMermaid.ts
Normal file
334
apps/web/src/shared/composables/useMermaid.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { getRandomId } from "utils/functions"
|
||||
|
||||
const mermaidThemeVariables = {
|
||||
primaryColor: "#e0f2fe",
|
||||
primaryTextColor: "#0f172a",
|
||||
primaryBorderColor: "#0284c7",
|
||||
lineColor: "#64748b",
|
||||
arrowheadColor: "#64748b",
|
||||
secondaryColor: "#f5f3ff",
|
||||
tertiaryColor: "#ecfdf5",
|
||||
background: "#ffffff",
|
||||
mainBkg: "#f8fafc",
|
||||
secondBkg: "#eef2ff",
|
||||
tertiaryBkg: "#f0fdfa",
|
||||
nodeBorder: "#2563eb",
|
||||
clusterBkg: "#f8fafc",
|
||||
clusterBorder: "#cbd5e1",
|
||||
edgeLabelBackground: "#ffffff",
|
||||
fontFamily:
|
||||
'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
}
|
||||
|
||||
const semanticNodeClasses = [
|
||||
"startNode",
|
||||
"endNode",
|
||||
"startEnd",
|
||||
"input",
|
||||
"output",
|
||||
"process",
|
||||
"decision",
|
||||
"loop",
|
||||
]
|
||||
|
||||
const displayStyleId = "oj-mermaid-display-style"
|
||||
|
||||
const mermaidDisplayStyle = `
|
||||
.oj-mermaid-flowchart {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node rect,
|
||||
.oj-mermaid-flowchart g.node polygon,
|
||||
.oj-mermaid-flowchart g.node ellipse,
|
||||
.oj-mermaid-flowchart g.node circle,
|
||||
.oj-mermaid-flowchart g.node path {
|
||||
stroke-width: 2px !important;
|
||||
filter: drop-shadow(0 6px 12px rgba(15, 23, 42, 0.12));
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.startNode rect,
|
||||
.oj-mermaid-flowchart g.node.startNode polygon,
|
||||
.oj-mermaid-flowchart g.node.startNode ellipse,
|
||||
.oj-mermaid-flowchart g.node.startNode circle,
|
||||
.oj-mermaid-flowchart g.node.startNode path,
|
||||
.oj-mermaid-flowchart g.node.startEnd rect,
|
||||
.oj-mermaid-flowchart g.node.startEnd polygon,
|
||||
.oj-mermaid-flowchart g.node.startEnd ellipse,
|
||||
.oj-mermaid-flowchart g.node.startEnd circle,
|
||||
.oj-mermaid-flowchart g.node.startEnd path {
|
||||
fill: #dcfce7 !important;
|
||||
stroke: #16a34a !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.endNode rect,
|
||||
.oj-mermaid-flowchart g.node.endNode polygon,
|
||||
.oj-mermaid-flowchart g.node.endNode ellipse,
|
||||
.oj-mermaid-flowchart g.node.endNode circle,
|
||||
.oj-mermaid-flowchart g.node.endNode path {
|
||||
fill: #fee2e2 !important;
|
||||
stroke: #dc2626 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.input rect,
|
||||
.oj-mermaid-flowchart g.node.input polygon,
|
||||
.oj-mermaid-flowchart g.node.input ellipse,
|
||||
.oj-mermaid-flowchart g.node.input circle,
|
||||
.oj-mermaid-flowchart g.node.input path {
|
||||
fill: #dbeafe !important;
|
||||
stroke: #2563eb !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.output rect,
|
||||
.oj-mermaid-flowchart g.node.output polygon,
|
||||
.oj-mermaid-flowchart g.node.output ellipse,
|
||||
.oj-mermaid-flowchart g.node.output circle,
|
||||
.oj-mermaid-flowchart g.node.output path {
|
||||
fill: #ede9fe !important;
|
||||
stroke: #7c3aed !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.process rect,
|
||||
.oj-mermaid-flowchart g.node.process polygon,
|
||||
.oj-mermaid-flowchart g.node.process ellipse,
|
||||
.oj-mermaid-flowchart g.node.process circle,
|
||||
.oj-mermaid-flowchart g.node.process path {
|
||||
fill: #f0f9ff !important;
|
||||
stroke: #0284c7 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.decision rect,
|
||||
.oj-mermaid-flowchart g.node.decision polygon,
|
||||
.oj-mermaid-flowchart g.node.decision ellipse,
|
||||
.oj-mermaid-flowchart g.node.decision circle,
|
||||
.oj-mermaid-flowchart g.node.decision path {
|
||||
fill: #fef3c7 !important;
|
||||
stroke: #d97706 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.loop rect,
|
||||
.oj-mermaid-flowchart g.node.loop polygon,
|
||||
.oj-mermaid-flowchart g.node.loop ellipse,
|
||||
.oj-mermaid-flowchart g.node.loop circle,
|
||||
.oj-mermaid-flowchart g.node.loop path {
|
||||
fill: #fae8ff !important;
|
||||
stroke: #c026d3 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-0 rect,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-0 polygon,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-0 ellipse,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-0 circle,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-0 path {
|
||||
fill: #dbeafe !important;
|
||||
stroke: #2563eb !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-1 rect,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-1 polygon,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-1 ellipse,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-1 circle,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-1 path {
|
||||
fill: #ccfbf1 !important;
|
||||
stroke: #0d9488 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-2 rect,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-2 polygon,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-2 ellipse,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-2 circle,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-2 path {
|
||||
fill: #ede9fe !important;
|
||||
stroke: #7c3aed !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-3 rect,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-3 polygon,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-3 ellipse,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-3 circle,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-3 path {
|
||||
fill: #ffe4e6 !important;
|
||||
stroke: #e11d48 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-4 rect,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-4 polygon,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-4 ellipse,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-4 circle,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-4 path {
|
||||
fill: #fef3c7 !important;
|
||||
stroke: #d97706 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-5 rect,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-5 polygon,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-5 ellipse,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-5 circle,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-5 path {
|
||||
fill: #dcfce7 !important;
|
||||
stroke: #16a34a !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node .label,
|
||||
.oj-mermaid-flowchart g.node .nodeLabel,
|
||||
.oj-mermaid-flowchart g.node .nodeLabel p,
|
||||
.oj-mermaid-flowchart g.node .label span {
|
||||
color: #0f172a !important;
|
||||
fill: #0f172a !important;
|
||||
font-weight: 650 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart .edgePaths path.path,
|
||||
.oj-mermaid-flowchart .flowchart-link {
|
||||
stroke: #64748b !important;
|
||||
stroke-width: 2.4px !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart marker path,
|
||||
.oj-mermaid-flowchart .marker {
|
||||
fill: #64748b !important;
|
||||
stroke: #64748b !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart .edgeLabel rect,
|
||||
.oj-mermaid-flowchart .edgeLabel .labelBkg {
|
||||
fill: rgba(255, 255, 255, 0.94) !important;
|
||||
stroke: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart .edgeLabel,
|
||||
.oj-mermaid-flowchart .edgeLabel span,
|
||||
.oj-mermaid-flowchart .edgeLabel p {
|
||||
color: #334155 !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
`
|
||||
|
||||
const svgNamespace = "http://www.w3.org/2000/svg"
|
||||
|
||||
function getNodeLabel(node: SVGGElement): string {
|
||||
const el =
|
||||
node.querySelector(".nodeLabel p") ||
|
||||
node.querySelector(".nodeLabel") ||
|
||||
node.querySelector(".label span") ||
|
||||
node.querySelector(".label")
|
||||
return el?.textContent?.trim() ?? ""
|
||||
}
|
||||
|
||||
function applyFlowchartDisplayStyle(container: HTMLElement) {
|
||||
container.classList.add("oj-mermaid-surface")
|
||||
|
||||
const svg = container.querySelector("svg")
|
||||
if (!svg) return
|
||||
|
||||
svg.classList.add("oj-mermaid-flowchart")
|
||||
|
||||
const nodes = Array.from(svg.querySelectorAll<SVGGElement>("g.node"))
|
||||
|
||||
// Assign palette indices by label so same label → same color, different labels → different colors
|
||||
const labelPaletteMap = new Map<string, number>()
|
||||
let paletteCounter = 0
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const hasSemanticClass = semanticNodeClasses.some((className) =>
|
||||
node.classList.contains(className),
|
||||
)
|
||||
if (!hasSemanticClass) {
|
||||
const label = getNodeLabel(node)
|
||||
if (!labelPaletteMap.has(label)) {
|
||||
labelPaletteMap.set(label, paletteCounter % 6)
|
||||
paletteCounter++
|
||||
}
|
||||
node.classList.add(`oj-node-palette-${labelPaletteMap.get(label)}`)
|
||||
}
|
||||
})
|
||||
|
||||
svg.querySelector(`#${displayStyleId}`)?.remove()
|
||||
const style = document.createElementNS(svgNamespace, "style")
|
||||
style.setAttribute("id", displayStyleId)
|
||||
style.textContent = mermaidDisplayStyle
|
||||
svg.insertBefore(style, svg.firstChild)
|
||||
}
|
||||
|
||||
function getChromeVersion(): number {
|
||||
const match = navigator.userAgent.match(/Chrome\/(\d+)/)
|
||||
return match ? parseInt(match[1]) : Infinity
|
||||
}
|
||||
|
||||
let mermaidInstance: any = null
|
||||
let mermaidIsLegacy = false
|
||||
|
||||
async function loadMermaid() {
|
||||
if (!mermaidInstance) {
|
||||
if (getChromeVersion() < 94) {
|
||||
mermaidInstance = (await import("mermaid-legacy")).default
|
||||
mermaidIsLegacy = true
|
||||
} else {
|
||||
mermaidInstance = (await import("mermaid")).default
|
||||
}
|
||||
mermaidInstance.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
theme: "base",
|
||||
themeVariables: mermaidThemeVariables,
|
||||
})
|
||||
}
|
||||
return mermaidInstance
|
||||
}
|
||||
|
||||
export function useMermaid() {
|
||||
const renderError = ref<string | null>(null)
|
||||
const renderSuccess = ref(false)
|
||||
let renderGeneration = 0
|
||||
|
||||
const renderFlowchart = async (
|
||||
container: HTMLElement | null,
|
||||
mermaidCode: string,
|
||||
) => {
|
||||
renderError.value = null
|
||||
renderSuccess.value = false
|
||||
|
||||
if (container) container.innerHTML = ""
|
||||
|
||||
if (!container || !mermaidCode?.trim()) return
|
||||
|
||||
const gen = ++renderGeneration
|
||||
try {
|
||||
const m = await loadMermaid()
|
||||
const id = `mermaid-${getRandomId()}`
|
||||
// v9 (mermaid-legacy): callback-based render(id, code, cb)
|
||||
// v10+: Promise-based render(id, code) → { svg }
|
||||
const svg = mermaidIsLegacy
|
||||
? await new Promise<string>((resolve, reject) => {
|
||||
try {
|
||||
m.render(id, mermaidCode, resolve)
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
: (await m.render(id, mermaidCode)).svg
|
||||
if (gen !== renderGeneration) return
|
||||
container.innerHTML = svg
|
||||
applyFlowchartDisplayStyle(container)
|
||||
renderSuccess.value = true
|
||||
} catch (error) {
|
||||
if (gen !== renderGeneration) return
|
||||
renderError.value =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "流程图渲染失败,请检查代码格式"
|
||||
}
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
renderError.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
renderError: readonly(renderError),
|
||||
renderSuccess: readonly(renderSuccess),
|
||||
renderFlowchart,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
544
apps/web/src/shared/composables/websocket.ts
Normal file
544
apps/web/src/shared/composables/websocket.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
import { ref, onUnmounted, type Ref } from "vue"
|
||||
|
||||
/**
|
||||
* WebSocket 连接状态
|
||||
*/
|
||||
export type ConnectionStatus =
|
||||
"disconnected" | "connecting" | "connected" | "error"
|
||||
|
||||
/**
|
||||
* WebSocket 消息类型
|
||||
*/
|
||||
export interface WebSocketMessage {
|
||||
type: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 配置
|
||||
*/
|
||||
export interface WebSocketConfig {
|
||||
/** WebSocket 路径(如 '/ws/submission/') */
|
||||
path: string
|
||||
/** 最大重连次数,默认 5 */
|
||||
maxReconnectAttempts?: number
|
||||
/** 重连延迟(毫秒),默认 1000 */
|
||||
reconnectDelay?: number
|
||||
/** 心跳间隔(毫秒),默认 30000(30秒) */
|
||||
heartbeatTime?: number
|
||||
/** 是否启用心跳,默认 true */
|
||||
enableHeartbeat?: boolean
|
||||
/** 是否启用自动重连,默认 true */
|
||||
enableAutoReconnect?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 消息处理器
|
||||
*/
|
||||
export type MessageHandler<T extends WebSocketMessage = WebSocketMessage> = (
|
||||
data: T,
|
||||
) => void
|
||||
|
||||
/**
|
||||
* WebSocket 基础连接管理类
|
||||
* 提供连接、重连、心跳等通用功能
|
||||
*/
|
||||
export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
protected ws: WebSocket | null = null
|
||||
protected url: string
|
||||
protected handlers: Set<MessageHandler<T>> = new Set()
|
||||
protected reconnectAttempts = 0
|
||||
protected maxReconnectAttempts: number
|
||||
protected reconnectDelay: number
|
||||
protected heartbeatInterval: number | null = null
|
||||
protected heartbeatTime: number
|
||||
protected enableHeartbeat: boolean
|
||||
protected enableAutoReconnect: boolean
|
||||
protected disconnectTimer: number | null = null
|
||||
|
||||
public status: Ref<ConnectionStatus> = ref<ConnectionStatus>("disconnected")
|
||||
|
||||
constructor(config: WebSocketConfig) {
|
||||
this.url = `${import.meta.env.PUBLIC_WS_URL}/${config.path}/`
|
||||
|
||||
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 5
|
||||
this.reconnectDelay = config.reconnectDelay ?? 1000
|
||||
this.heartbeatTime = config.heartbeatTime ?? 30000
|
||||
this.enableHeartbeat = config.enableHeartbeat ?? true
|
||||
this.enableAutoReconnect = config.enableAutoReconnect ?? true
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 WebSocket
|
||||
*/
|
||||
connect() {
|
||||
if (
|
||||
this.ws &&
|
||||
(this.ws.readyState === WebSocket.OPEN ||
|
||||
this.ws.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.status.value = "connecting"
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.status.value = "connected"
|
||||
this.reconnectAttempts = 0
|
||||
console.log(`[WebSocket] 连接成功: ${this.url}`)
|
||||
if (this.enableHeartbeat) {
|
||||
this.startHeartbeat()
|
||||
}
|
||||
this.onConnected()
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as T
|
||||
console.log(`[WebSocket] 收到消息:`, data)
|
||||
|
||||
// 处理心跳响应
|
||||
if (data.type === "pong") {
|
||||
return
|
||||
}
|
||||
|
||||
// 调用消息处理钩子
|
||||
this.onMessage(data)
|
||||
} catch (error) {
|
||||
console.error("[WebSocket] 解析消息失败:", error)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error("[WebSocket] 连接错误:", error)
|
||||
this.status.value = "error"
|
||||
this.onError(error)
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log(
|
||||
`[WebSocket] 连接关闭: code=${event.code}, reason=${event.reason}`,
|
||||
)
|
||||
this.status.value = "disconnected"
|
||||
this.stopHeartbeat()
|
||||
this.onDisconnected(event)
|
||||
|
||||
// 自动重连
|
||||
if (
|
||||
this.enableAutoReconnect &&
|
||||
this.reconnectAttempts < this.maxReconnectAttempts
|
||||
) {
|
||||
this.reconnectAttempts++
|
||||
const delay = this.reconnectDelay * this.reconnectAttempts
|
||||
console.log(
|
||||
`[WebSocket] 将在 ${delay}ms 后重连 (尝试 ${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
|
||||
)
|
||||
setTimeout(() => this.connect(), delay)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create WebSocket connection:", error)
|
||||
this.status.value = "error"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
disconnect() {
|
||||
this.cancelScheduledDisconnect()
|
||||
this.stopHeartbeat()
|
||||
this.enableAutoReconnect = false // 停止自动重连
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
this.status.value = "disconnected"
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排延迟断开连接
|
||||
* @param delay 延迟时间(毫秒),默认 900000(15分钟)
|
||||
*/
|
||||
scheduleDisconnect(delay: number = 15 * 60 * 1000) {
|
||||
// 取消之前的定时器
|
||||
this.cancelScheduledDisconnect()
|
||||
|
||||
// 设置新的定时器
|
||||
this.disconnectTimer = window.setTimeout(() => {
|
||||
const minutes = Math.floor(delay / 60000)
|
||||
console.log(`WebSocket idle for ${minutes} minutes, disconnecting...`)
|
||||
this.disconnect()
|
||||
// 断开后需要重新允许自动重连
|
||||
this.enableAutoReconnect = true
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消已安排的断开连接
|
||||
*/
|
||||
cancelScheduledDisconnect() {
|
||||
if (this.disconnectTimer !== null) {
|
||||
clearTimeout(this.disconnectTimer)
|
||||
this.disconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
send(data: any) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加消息处理器
|
||||
*/
|
||||
addHandler(handler: MessageHandler<T>) {
|
||||
this.handlers.add(handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除消息处理器
|
||||
*/
|
||||
removeHandler(handler: MessageHandler<T>) {
|
||||
this.handlers.delete(handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有处理器
|
||||
*/
|
||||
clearHandlers() {
|
||||
this.handlers.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送心跳包
|
||||
*/
|
||||
protected sendHeartbeat() {
|
||||
this.send({ type: "ping", timestamp: Date.now() })
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始心跳
|
||||
*/
|
||||
protected startHeartbeat() {
|
||||
this.stopHeartbeat()
|
||||
this.heartbeatInterval = window.setInterval(() => {
|
||||
this.sendHeartbeat()
|
||||
}, this.heartbeatTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止心跳
|
||||
*/
|
||||
protected stopHeartbeat() {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval)
|
||||
this.heartbeatInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接成功钩子(子类可重写)
|
||||
*/
|
||||
protected onConnected() {
|
||||
// 子类实现
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接钩子(子类可重写)
|
||||
*/
|
||||
protected onDisconnected(event: CloseEvent) {
|
||||
// 子类实现
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误钩子(子类可重写)
|
||||
*/
|
||||
protected onError(error: Event) {
|
||||
// 子类实现
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息处理钩子(子类可重写)
|
||||
*/
|
||||
protected onMessage(data: T) {
|
||||
// 通知所有处理器
|
||||
this.handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(data)
|
||||
} catch (error) {
|
||||
console.error("Error in message handler:", error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交状态更新的数据类型
|
||||
*/
|
||||
export interface SubmissionUpdate extends WebSocketMessage {
|
||||
type: "submission_update"
|
||||
submission_id: string
|
||||
result: number
|
||||
status: "pending" | "judging" | "finished" | "error"
|
||||
time_cost?: number
|
||||
memory_cost?: number
|
||||
score?: number
|
||||
err_info?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交 WebSocket 连接管理类
|
||||
*/
|
||||
class SubmissionWebSocket extends BaseWebSocket<SubmissionUpdate> {
|
||||
constructor() {
|
||||
super({
|
||||
path: "submission",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅特定提交的更新
|
||||
*/
|
||||
subscribe(submissionId: string) {
|
||||
const success = this.send({
|
||||
type: "subscribe",
|
||||
submission_id: submissionId,
|
||||
})
|
||||
if (!success) {
|
||||
console.error("[WebSocket] 订阅失败: 连接未就绪")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于组件中使用 WebSocket 的 Composable
|
||||
* 每次调用创建新的 WebSocket 实例
|
||||
*/
|
||||
export function useSubmissionWebSocket(
|
||||
handler?: MessageHandler<SubmissionUpdate>,
|
||||
) {
|
||||
const ws = new SubmissionWebSocket()
|
||||
|
||||
// 如果提供了处理器,添加到实例中
|
||||
if (handler) {
|
||||
ws.addHandler(handler)
|
||||
}
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onUnmounted(() => {
|
||||
if (handler) {
|
||||
ws.removeHandler(handler)
|
||||
}
|
||||
ws.disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
connect: () => ws.connect(),
|
||||
disconnect: () => ws.disconnect(),
|
||||
subscribe: (submissionId: string) => ws.subscribe(submissionId),
|
||||
scheduleDisconnect: (delay?: number) => ws.scheduleDisconnect(delay),
|
||||
cancelScheduledDisconnect: () => ws.cancelScheduledDisconnect(),
|
||||
status: ws.status,
|
||||
addHandler: (h: MessageHandler<SubmissionUpdate>) => ws.addHandler(h),
|
||||
removeHandler: (h: MessageHandler<SubmissionUpdate>) => ws.removeHandler(h),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 WebSocket Composable 工厂函数
|
||||
* 用于创建自定义的 WebSocket composable
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // 创建通知 WebSocket
|
||||
* interface NotificationMessage extends WebSocketMessage {
|
||||
* type: 'notification'
|
||||
* title: string
|
||||
* content: string
|
||||
* }
|
||||
*
|
||||
* class NotificationWebSocket extends BaseWebSocket<NotificationMessage> {
|
||||
* constructor() {
|
||||
* super({ path: 'notification' })
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* let notificationWs: NotificationWebSocket | null = null
|
||||
*
|
||||
* export function useNotificationWebSocket(handler?: MessageHandler<NotificationMessage>) {
|
||||
* if (!notificationWs) {
|
||||
* notificationWs = new NotificationWebSocket()
|
||||
* }
|
||||
* return createWebSocketComposable(notificationWs, handler)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function createWebSocketComposable<T extends WebSocketMessage>(
|
||||
ws: BaseWebSocket<T>,
|
||||
handler?: MessageHandler<T>,
|
||||
) {
|
||||
if (handler) {
|
||||
ws.addHandler(handler)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (handler) {
|
||||
ws.removeHandler(handler)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
connect: () => ws.connect(),
|
||||
disconnect: () => ws.disconnect(),
|
||||
send: (data: any) => ws.send(data),
|
||||
status: ws.status,
|
||||
addHandler: (h: MessageHandler<T>) => ws.addHandler(h),
|
||||
removeHandler: (h: MessageHandler<T>) => ws.removeHandler(h),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程图评分更新消息类型
|
||||
*/
|
||||
export interface FlowchartEvaluationUpdate extends WebSocketMessage {
|
||||
type:
|
||||
| "flowchart_evaluation_completed"
|
||||
| "flowchart_evaluation_failed"
|
||||
| "flowchart_evaluation_update"
|
||||
submission_id: string
|
||||
score?: number
|
||||
grade?: string
|
||||
feedback?: string
|
||||
suggestions?: string
|
||||
criteriaDetails?: any
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程图 WebSocket 连接管理类
|
||||
*/
|
||||
class FlowchartWebSocket extends BaseWebSocket<FlowchartEvaluationUpdate> {
|
||||
constructor() {
|
||||
super({
|
||||
path: "flowchart", // 使用专门的 flowchart WebSocket 路径
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅特定流程图提交的更新
|
||||
*/
|
||||
subscribe(submissionId: string) {
|
||||
const success = this.send({
|
||||
type: "subscribe",
|
||||
submission_id: submissionId,
|
||||
})
|
||||
if (!success) {
|
||||
console.error("[Flowchart WebSocket] 订阅失败: 连接未就绪")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于组件中使用流程图 WebSocket 的 Composable
|
||||
*/
|
||||
export function useFlowchartWebSocket(
|
||||
handler?: MessageHandler<FlowchartEvaluationUpdate>,
|
||||
) {
|
||||
const ws = new FlowchartWebSocket()
|
||||
|
||||
// 如果提供了处理器,添加到实例中
|
||||
if (handler) {
|
||||
ws.addHandler(handler)
|
||||
}
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onUnmounted(() => {
|
||||
if (handler) {
|
||||
ws.removeHandler(handler)
|
||||
}
|
||||
ws.disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
connect: () => ws.connect(),
|
||||
disconnect: () => ws.disconnect(),
|
||||
subscribe: (submissionId: string) => ws.subscribe(submissionId),
|
||||
scheduleDisconnect: (delay?: number) => ws.scheduleDisconnect(delay),
|
||||
cancelScheduledDisconnect: () => ws.cancelScheduledDisconnect(),
|
||||
status: ws.status,
|
||||
addHandler: (h: MessageHandler<FlowchartEvaluationUpdate>) =>
|
||||
ws.addHandler(h),
|
||||
removeHandler: (h: MessageHandler<FlowchartEvaluationUpdate>) =>
|
||||
ws.removeHandler(h),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置更新消息类型
|
||||
*/
|
||||
export interface ConfigUpdate extends WebSocketMessage {
|
||||
type: "config_update"
|
||||
key: string
|
||||
value: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置 WebSocket 连接管理类
|
||||
*/
|
||||
class ConfigWebSocket extends BaseWebSocket<ConfigUpdate> {
|
||||
constructor() {
|
||||
super({
|
||||
path: "config",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送配置更新
|
||||
*/
|
||||
updateConfig(key: string, value: any) {
|
||||
this.send({
|
||||
type: "config_update",
|
||||
key,
|
||||
value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于组件中使用配置 WebSocket 的 Composable
|
||||
*/
|
||||
export function useConfigWebSocket(handler?: MessageHandler<ConfigUpdate>) {
|
||||
const ws = new ConfigWebSocket()
|
||||
|
||||
onMounted(() => {
|
||||
if (handler) {
|
||||
ws.addHandler(handler)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (handler) {
|
||||
ws.removeHandler(handler)
|
||||
}
|
||||
ws.disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
connect: () => ws.connect(),
|
||||
disconnect: () => ws.disconnect(),
|
||||
updateConfig: (key: string, value: any) => ws.updateConfig(key, value),
|
||||
status: ws.status,
|
||||
addHandler: (h: MessageHandler<ConfigUpdate>) => ws.addHandler(h),
|
||||
removeHandler: (h: MessageHandler<ConfigUpdate>) => ws.removeHandler(h),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user