diff --git a/rsbuild.config.ts b/rsbuild.config.ts index c6cf405..975e491 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -16,6 +16,14 @@ export default defineConfig(({ envMode }) => { headers: { Referer: url }, changeOrigin: true, } + + // WebSocket 代理配置(开发环境 Daphne 在 8001 端口) + const wsProxyConfig = { + target: url.replace(':8000', ':8001').replace('http:', 'ws:'), // http://localhost:8001 → ws://localhost:8001 + ws: true, // 启用 WebSocket 代理 + changeOrigin: true, + logLevel: 'debug' as const, // 显示代理日志 + } return { plugins: [pluginVue()], tools: { @@ -141,6 +149,7 @@ export default defineConfig(({ envMode }) => { proxy: { "/api": proxyConfig, "/public": proxyConfig, + "/ws": wsProxyConfig, // WebSocket 使用单独的代理配置 }, }, } diff --git a/src/oj/problem/components/Submit.vue b/src/oj/problem/components/Submit.vue index 4537919..9bef3b3 100644 --- a/src/oj/problem/components/Submit.vue +++ b/src/oj/problem/components/Submit.vue @@ -9,6 +9,10 @@ import { submissionMemoryFormat, submissionTimeFormat } from "utils/functions" import { Submission, SubmitCodePayload } from "utils/types" import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import { isDesktop } from "shared/composables/breakpoints" +import { + useSubmissionWebSocket, + type SubmissionUpdate, +} from "shared/composables/websocket" import { useUserStore } from "shared/store/user" const ProblemComment = defineAsyncComponent( @@ -39,7 +43,7 @@ const { start: showCommentPanel } = useTimeoutFn( { immediate: false }, ) -const { start: fetchSubmission } = useTimeoutFn( +const { start: fetchSubmission, stop: stopFetchSubmission } = useTimeoutFn( async () => { const res = await getSubmission(submissionId.value) submission.value = res.data @@ -57,45 +61,76 @@ const { start: fetchSubmission } = useTimeoutFn( { immediate: false }, ) +// WebSocket 消息处理器 +const handleSubmissionUpdate = (data: SubmissionUpdate) => { + console.log("[Submit] 收到提交更新:", data) + + if (data.submission_id !== submissionId.value) { + console.log(`[Submit] 提交ID不匹配: 期望=${submissionId.value}, 实际=${data.submission_id}`) + return + } + + if (!submission.value) { + submission.value = {} as Submission + } + + submission.value.result = data.result as Submission["result"] + + // 判题完成,获取完整提交详情 + if (data.status === "finished") { + console.log("[Submit] 判题完成,获取详细信息") + getSubmission(submissionId.value).then((res) => { + submission.value = res.data + submitted.value = false + + // 判题完成后,15 分钟无新提交则断开 WebSocket 连接 + // 考虑到学生换题间隔约 10 分钟,15 分钟可覆盖大部分场景 + scheduleDisconnect(15 * 60 * 1000) + }) + } else if (data.status === "error") { + console.log("[Submit] 判题出错") + submitted.value = false + // 判题出错后,15 分钟无新提交则断开 WebSocket 连接 + scheduleDisconnect(15 * 60 * 1000) + } +} + +// 初始化 WebSocket(按需连接模式) +const { + connect, + subscribe, + scheduleDisconnect, + cancelScheduledDisconnect, + status: wsStatus, +} = useSubmissionWebSocket(handleSubmissionUpdate) + +// 组件卸载时停止轮询 +onUnmounted(() => { + stopFetchSubmission() +}) + const judging = computed( - () => - !!( - submission.value && submission.value.result === SubmissionStatus.judging - ), + () => submission.value?.result === SubmissionStatus.judging, ) const pending = computed( - () => - !!( - submission.value && submission.value.result === SubmissionStatus.pending - ), + () => submission.value?.result === SubmissionStatus.pending, ) const submitting = computed( - () => - !!( - submission.value && - submission.value.result === SubmissionStatus.submitting - ), + () => submission.value?.result === SubmissionStatus.submitting, ) const submitDisabled = computed(() => { - if (!userStore.isAuthed) { - return true - } - if (code.value.trim() === "") { - return true - } - if (judging.value || pending.value || submitting.value) { - return true - } - if (submitted.value) { - return true - } - if (isPending.value) { - return true - } - return false + return ( + !userStore.isAuthed || + code.value.trim() === "" || + judging.value || + pending.value || + submitting.value || + submitted.value || + isPending.value + ) }) const submitLabel = computed(() => { @@ -115,43 +150,39 @@ const submitLabel = computed(() => { }) const msg = computed(() => { + if (!submission.value) return "" + let msg = "" - const result = submission.value && submission.value.result + const result = submission.value.result + if ( result === SubmissionStatus.compile_error || result === SubmissionStatus.runtime_error ) { msg += "请仔细检查,看看代码的格式是不是写错了!\n\n" } - if ( - submission.value && - submission.value.statistic_info && - submission.value.statistic_info.err_info - ) { + + if (submission.value.statistic_info?.err_info) { msg += submission.value.statistic_info.err_info } + return msg }) const infoTable = computed(() => { + if (!submission.value?.info?.data?.length) return [] + + const result = submission.value.result if ( - submission.value && - submission.value.result !== SubmissionStatus.accepted && - submission.value.result !== SubmissionStatus.compile_error && - submission.value.result !== SubmissionStatus.runtime_error && - submission.value.info && - submission.value.info.data && - submission.value.info.data.length + result === SubmissionStatus.accepted || + result === SubmissionStatus.compile_error || + result === SubmissionStatus.runtime_error ) { - const data = submission.value.info.data - if (data.some((item) => item.result === 0)) { - return submission.value.info.data - } else { - return [] - } - } else { return [] } + + const data = submission.value.info.data + return data.some((item) => item.result === 0) ? data : [] }) const columns: DataTableColumn[] = [ @@ -175,9 +206,8 @@ const columns: DataTableColumn[] = [ ] async function submit() { - if (!userStore.isAuthed) { - return - } + if (!userStore.isAuthed) return + const data: SubmitCodePayload = { problem_id: problem.value!.id, language: code.language, @@ -186,35 +216,73 @@ async function submit() { if (contestID) { data.contest_id = parseInt(contestID) } + submission.value = { result: 9 } as Submission const res = await submitCode(data) submissionId.value = res.data.submission_id - // 防止重复提交 + console.log(`[Submit] 代码已提交: ID=${submissionId.value}`) submitPending() submitted.value = true - // 查询结果 - fetchSubmission() + + // 取消之前安排的断开倒计时(如果有新提交) + cancelScheduledDisconnect() + + // 按需连接 WebSocket + if (wsStatus.value !== "connected") { + console.log(`[Submit] WebSocket 未连接,正在连接... 当前状态: ${wsStatus.value}`) + connect() + } else { + console.log("[Submit] WebSocket 已连接,直接订阅") + } + + // 优先使用 WebSocket 实时更新 + if (wsStatus.value === "connected" || wsStatus.value === "connecting") { + // 等待连接完成后订阅 + const checkConnection = setInterval(() => { + if (wsStatus.value === "connected") { + clearInterval(checkConnection) + console.log(`[Submit] 订阅提交更新: ID=${submissionId.value}`) + subscribe(submissionId.value) + } + }, 100) + + // 5 秒后如果还在判题中,降级到轮询作为保险 + // 考虑到判题一般 1-3 秒完成,5 秒足以覆盖绝大部分情况 + setTimeout(() => { + clearInterval(checkConnection) + if ( + submission.value && + (submission.value.result === SubmissionStatus.judging || + submission.value.result === SubmissionStatus.pending || + submission.value.result === 9) + ) { + console.log("WebSocket 未及时响应,降级到轮询模式") + fetchSubmission() + } + }, 5000) + } else { + // WebSocket 连接失败,直接使用轮询 + fetchSubmission() + } } watch( - () => submission?.value?.result, + () => submission.value?.result, (result) => { - if (result === SubmissionStatus.accepted) { - // 刷新题目状态 - problem.value!.my_status = 0 - // 放烟花 - confetti({ - particleCount: 300, - startVelocity: 30, - gravity: 0.5, - spread: 350, - origin: { x: 0.5, y: 0.4 }, - }) - // 题目在第一次完成之后,弹出点评框 - if (!contestID) { - showCommentPanel() - } - } + if (result !== SubmissionStatus.accepted) return + + // 刷新题目状态 + problem.value!.my_status = 0 + // 放烟花 + confetti({ + particleCount: 300, + startVelocity: 30, + gravity: 0.5, + spread: 350, + origin: { x: 0.5, y: 0.4 }, + }) + // 题目在第一次完成之后,弹出点评框 + if (!contestID) showCommentPanel() }, ) diff --git a/src/shared/composables/websocket.ts b/src/shared/composables/websocket.ts new file mode 100644 index 0000000..53048c3 --- /dev/null +++ b/src/shared/composables/websocket.ts @@ -0,0 +1,425 @@ +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 = ( + data: T, +) => void + +/** + * WebSocket 基础连接管理类 + * 提供连接、重连、心跳等通用功能 + */ +export class BaseWebSocket { + protected ws: WebSocket | null = null + protected url: string + protected handlers: Set> = 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 = ref("disconnected") + + constructor(config: WebSocketConfig) { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" + const host = window.location.host + this.url = `${protocol}//${host}/ws/${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) { + this.handlers.add(handler) + } + + /** + * 移除消息处理器 + */ + removeHandler(handler: MessageHandler) { + 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 { + constructor() { + super({ + path: "submission", + }) + } + + /** + * 订阅特定提交的更新 + */ + subscribe(submissionId: string) { + console.log(`[WebSocket] 发送订阅请求: submission_id=${submissionId}`) + const success = this.send({ + type: "subscribe", + submission_id: submissionId, + }) + if (!success) { + console.error("[WebSocket] 订阅失败: 连接未就绪") + } + } +} + +// 全局单例 +let wsInstance: SubmissionWebSocket | null = null + +/** + * 获取 WebSocket 实例 + */ +export function getWebSocketInstance(): SubmissionWebSocket { + if (!wsInstance) { + wsInstance = new SubmissionWebSocket() + } + return wsInstance +} + +/** + * 用于组件中使用 WebSocket 的 Composable + */ +export function useSubmissionWebSocket( + handler?: MessageHandler, +) { + const ws = getWebSocketInstance() + + // 如果提供了处理器,添加到实例中 + if (handler) { + ws.addHandler(handler) + } + + // 组件卸载时移除处理器 + onUnmounted(() => { + if (handler) { + ws.removeHandler(handler) + } + }) + + 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) => ws.addHandler(h), + removeHandler: (h: MessageHandler) => ws.removeHandler(h), + } +} + +/** + * 通用 WebSocket Composable 工厂函数 + * 用于创建自定义的 WebSocket composable + * + * @example + * ```ts + * // 创建通知 WebSocket + * interface NotificationMessage extends WebSocketMessage { + * type: 'notification' + * title: string + * content: string + * } + * + * class NotificationWebSocket extends BaseWebSocket { + * constructor() { + * super({ path: 'notification' }) + * } + * } + * + * let notificationWs: NotificationWebSocket | null = null + * + * export function useNotificationWebSocket(handler?: MessageHandler) { + * if (!notificationWs) { + * notificationWs = new NotificationWebSocket() + * } + * return createWebSocketComposable(notificationWs, handler) + * } + * ``` + */ +export function createWebSocketComposable( + ws: BaseWebSocket, + handler?: MessageHandler, +) { + 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) => ws.addHandler(h), + removeHandler: (h: MessageHandler) => ws.removeHandler(h), + } +}