feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码

This commit is contained in:
2026-08-06 21:18:16 -06:00
parent 3c975e85ee
commit ae1fb329b5
258 changed files with 40490 additions and 91 deletions

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

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

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

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

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

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

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

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

View 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
/** 心跳间隔(毫秒),默认 3000030秒 */
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 延迟时间(毫秒),默认 90000015分钟
*/
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),
}
}