add ws
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
2025-10-07 17:04:35 +08:00
parent 437da9d588
commit 389393b70d
3 changed files with 575 additions and 73 deletions

View File

@@ -16,6 +16,14 @@ export default defineConfig(({ envMode }) => {
headers: { Referer: url }, headers: { Referer: url },
changeOrigin: true, 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 { return {
plugins: [pluginVue()], plugins: [pluginVue()],
tools: { tools: {
@@ -141,6 +149,7 @@ export default defineConfig(({ envMode }) => {
proxy: { proxy: {
"/api": proxyConfig, "/api": proxyConfig,
"/public": proxyConfig, "/public": proxyConfig,
"/ws": wsProxyConfig, // WebSocket 使用单独的代理配置
}, },
}, },
} }

View File

@@ -9,6 +9,10 @@ import { submissionMemoryFormat, submissionTimeFormat } from "utils/functions"
import { Submission, SubmitCodePayload } from "utils/types" import { Submission, SubmitCodePayload } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { isDesktop } from "shared/composables/breakpoints" import { isDesktop } from "shared/composables/breakpoints"
import {
useSubmissionWebSocket,
type SubmissionUpdate,
} from "shared/composables/websocket"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
const ProblemComment = defineAsyncComponent( const ProblemComment = defineAsyncComponent(
@@ -39,7 +43,7 @@ const { start: showCommentPanel } = useTimeoutFn(
{ immediate: false }, { immediate: false },
) )
const { start: fetchSubmission } = useTimeoutFn( const { start: fetchSubmission, stop: stopFetchSubmission } = useTimeoutFn(
async () => { async () => {
const res = await getSubmission(submissionId.value) const res = await getSubmission(submissionId.value)
submission.value = res.data submission.value = res.data
@@ -57,45 +61,76 @@ const { start: fetchSubmission } = useTimeoutFn(
{ immediate: false }, { 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( const judging = computed(
() => () => submission.value?.result === SubmissionStatus.judging,
!!(
submission.value && submission.value.result === SubmissionStatus.judging
),
) )
const pending = computed( const pending = computed(
() => () => submission.value?.result === SubmissionStatus.pending,
!!(
submission.value && submission.value.result === SubmissionStatus.pending
),
) )
const submitting = computed( const submitting = computed(
() => () => submission.value?.result === SubmissionStatus.submitting,
!!(
submission.value &&
submission.value.result === SubmissionStatus.submitting
),
) )
const submitDisabled = computed(() => { const submitDisabled = computed(() => {
if (!userStore.isAuthed) { return (
return true !userStore.isAuthed ||
} code.value.trim() === "" ||
if (code.value.trim() === "") { judging.value ||
return true pending.value ||
} submitting.value ||
if (judging.value || pending.value || submitting.value) { submitted.value ||
return true isPending.value
} )
if (submitted.value) {
return true
}
if (isPending.value) {
return true
}
return false
}) })
const submitLabel = computed(() => { const submitLabel = computed(() => {
@@ -115,43 +150,39 @@ const submitLabel = computed(() => {
}) })
const msg = computed(() => { const msg = computed(() => {
if (!submission.value) return ""
let msg = "" let msg = ""
const result = submission.value && submission.value.result const result = submission.value.result
if ( if (
result === SubmissionStatus.compile_error || result === SubmissionStatus.compile_error ||
result === SubmissionStatus.runtime_error result === SubmissionStatus.runtime_error
) { ) {
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n" msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
} }
if (
submission.value && if (submission.value.statistic_info?.err_info) {
submission.value.statistic_info &&
submission.value.statistic_info.err_info
) {
msg += submission.value.statistic_info.err_info msg += submission.value.statistic_info.err_info
} }
return msg return msg
}) })
const infoTable = computed(() => { const infoTable = computed(() => {
if (!submission.value?.info?.data?.length) return []
const result = submission.value.result
if ( if (
submission.value && result === SubmissionStatus.accepted ||
submission.value.result !== SubmissionStatus.accepted && result === SubmissionStatus.compile_error ||
submission.value.result !== SubmissionStatus.compile_error && result === SubmissionStatus.runtime_error
submission.value.result !== SubmissionStatus.runtime_error &&
submission.value.info &&
submission.value.info.data &&
submission.value.info.data.length
) { ) {
return []
}
const data = submission.value.info.data const data = submission.value.info.data
if (data.some((item) => item.result === 0)) { return data.some((item) => item.result === 0) ? data : []
return submission.value.info.data
} else {
return []
}
} else {
return []
}
}) })
const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
@@ -175,9 +206,8 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
] ]
async function submit() { async function submit() {
if (!userStore.isAuthed) { if (!userStore.isAuthed) return
return
}
const data: SubmitCodePayload = { const data: SubmitCodePayload = {
problem_id: problem.value!.id, problem_id: problem.value!.id,
language: code.language, language: code.language,
@@ -186,20 +216,61 @@ async function submit() {
if (contestID) { if (contestID) {
data.contest_id = parseInt(contestID) data.contest_id = parseInt(contestID)
} }
submission.value = { result: 9 } as Submission submission.value = { result: 9 } as Submission
const res = await submitCode(data) const res = await submitCode(data)
submissionId.value = res.data.submission_id submissionId.value = res.data.submission_id
// 防止重复提交 console.log(`[Submit] 代码已提交: ID=${submissionId.value}`)
submitPending() submitPending()
submitted.value = true submitted.value = true
// 查询结果
// 取消之前安排的断开倒计时(如果有新提交)
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() fetchSubmission()
} }
}, 5000)
} else {
// WebSocket 连接失败,直接使用轮询
fetchSubmission()
}
}
watch( watch(
() => submission?.value?.result, () => submission.value?.result,
(result) => { (result) => {
if (result === SubmissionStatus.accepted) { if (result !== SubmissionStatus.accepted) return
// 刷新题目状态 // 刷新题目状态
problem.value!.my_status = 0 problem.value!.my_status = 0
// 放烟花 // 放烟花
@@ -211,10 +282,7 @@ watch(
origin: { x: 0.5, y: 0.4 }, origin: { x: 0.5, y: 0.4 },
}) })
// 题目在第一次完成之后,弹出点评框 // 题目在第一次完成之后,弹出点评框
if (!contestID) { if (!contestID) showCommentPanel()
showCommentPanel()
}
}
}, },
) )
</script> </script>

View File

@@ -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
/** 心跳间隔(毫秒),默认 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) {
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 延迟时间(毫秒),默认 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) {
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<SubmissionUpdate>,
) {
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<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),
}
}