Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
530e1a3d5d
commit
195069450f
480 files changed
+15507
-24650
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { streamAI } from "utils/stream"
|
||||
import { streamAI } from 'utils/stream'
|
||||
|
||||
/**
|
||||
* 组件里发 AI 流式请求:同一时刻只跑一条(再点一次会先掐掉上一条),组件卸载时
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MaybeRefOrGetter, Ref } from "vue"
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 手机上整屏(或大半屏)的弹框、抽屉:按返回键先关它,而不是退出整页。
|
||||
@@ -26,7 +26,7 @@ export function useBackClose(show: Ref<boolean>, enabled: MaybeRefOrGetter<boole
|
||||
|
||||
watch(show, (open) => {
|
||||
if (open && !pushed && toValue(enabled)) {
|
||||
history.pushState(history.state, "")
|
||||
history.pushState(history.state, '')
|
||||
pushed = true
|
||||
router.listening = false
|
||||
} else if (!open && pushed) {
|
||||
@@ -35,7 +35,7 @@ export function useBackClose(show: Ref<boolean>, enabled: MaybeRefOrGetter<boole
|
||||
}
|
||||
})
|
||||
|
||||
useEventListener(window, "popstate", () => {
|
||||
useEventListener(window, 'popstate', () => {
|
||||
if (!pushed) return
|
||||
pushed = false
|
||||
// 按了返回键的:只管关(watch 里看到 pushed 已经是 false,不会再 back)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { breakpointsTailwind, useBreakpoints as useVueUseBreakpoints } from "@vueuse/core"
|
||||
import { breakpointsTailwind, useBreakpoints as useVueUseBreakpoints } from '@vueuse/core'
|
||||
|
||||
/**
|
||||
* 响应式断点检测 composable
|
||||
@@ -7,8 +7,8 @@ import { breakpointsTailwind, useBreakpoints as useVueUseBreakpoints } from "@vu
|
||||
export function useBreakpoints() {
|
||||
const breakpoints = useVueUseBreakpoints(breakpointsTailwind)
|
||||
|
||||
const isMobile = breakpoints.smallerOrEqual("md")
|
||||
const isDesktop = breakpoints.greater("md")
|
||||
const isMobile = breakpoints.smallerOrEqual('md')
|
||||
const isDesktop = breakpoints.greater('md')
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useDark } from "@vueuse/core"
|
||||
import { Chart as ChartJS } from "chart.js"
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { Chart as ChartJS } from 'chart.js'
|
||||
|
||||
/**
|
||||
* chart.js 的默认配色是写死的浅色(文字 #666、网格线 rgba(0,0,0,0.1)),深色主题下
|
||||
@@ -11,11 +11,9 @@ import { Chart as ChartJS } from "chart.js"
|
||||
export function useChartTheme() {
|
||||
const isDark = useDark()
|
||||
|
||||
const textColor = computed(() => (isDark.value ? "rgba(255, 255, 255, 0.75)" : "#606266"))
|
||||
const gridColor = computed(() =>
|
||||
isDark.value ? "rgba(255, 255, 255, 0.12)" : "rgba(0, 0, 0, 0.1)",
|
||||
)
|
||||
const chartKey = computed(() => (isDark.value ? "dark" : "light"))
|
||||
const textColor = computed(() => (isDark.value ? 'rgba(255, 255, 255, 0.75)' : '#606266'))
|
||||
const gridColor = computed(() => (isDark.value ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.1)'))
|
||||
const chartKey = computed(() => (isDark.value ? 'dark' : 'light'))
|
||||
|
||||
watchEffect(() => {
|
||||
ChartJS.defaults.color = textColor.value
|
||||
@@ -28,7 +26,7 @@ export function useChartTheme() {
|
||||
// 就整个罢工,靠它自动配色的图(DurationChart)会退成近黑的柱子和图例。
|
||||
// 也不要直接写 defaults.scales.linear.border —— 那要求对应的 scale 已经注册,
|
||||
// 只注册了 radialLinear 的雷达图会在 setup 里抛错。
|
||||
ChartJS.defaults.set("scale", {
|
||||
ChartJS.defaults.set('scale', {
|
||||
grid: { color: gridColor.value },
|
||||
border: { color: gridColor.value },
|
||||
})
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Compartment } from "@codemirror/state"
|
||||
import type { EditorView } from "@codemirror/view"
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { Compartment } from '@codemirror/state'
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import { useCollabStore } from 'shared/store/collab'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
|
||||
/** y-websocket 那套消息头,服务端不解析,只有两端认 */
|
||||
const MESSAGE_SYNC = 0
|
||||
const MESSAGE_AWARENESS = 1
|
||||
|
||||
const TEACHER_COLOR = "#ff6b6b"
|
||||
const STUDENT_COLOR = "#4dabf7"
|
||||
const TEACHER_COLOR = '#ff6b6b'
|
||||
const STUDENT_COLOR = '#4dabf7'
|
||||
|
||||
interface StartOptions {
|
||||
editorView: EditorView
|
||||
@@ -47,23 +47,21 @@ export function useCollabDoc() {
|
||||
if (doc) stop()
|
||||
const myGeneration = ++generation
|
||||
|
||||
const [Y, awarenessProtocol, syncProtocol, encoding, decoding, { yCollab }] = await Promise.all(
|
||||
[
|
||||
import("yjs"),
|
||||
import("y-protocols/awareness"),
|
||||
import("y-protocols/sync"),
|
||||
import("lib0/encoding"),
|
||||
import("lib0/decoding"),
|
||||
import("y-codemirror.next"),
|
||||
],
|
||||
)
|
||||
const [Y, awarenessProtocol, syncProtocol, encoding, decoding, { yCollab }] = await Promise.all([
|
||||
import('yjs'),
|
||||
import('y-protocols/awareness'),
|
||||
import('y-protocols/sync'),
|
||||
import('lib0/encoding'),
|
||||
import('lib0/decoding'),
|
||||
import('y-codemirror.next'),
|
||||
])
|
||||
|
||||
// 等 chunk 的这段时间里房间关了(或者又开了新的一轮),整个放弃
|
||||
if (myGeneration !== generation) return
|
||||
|
||||
view = editorView
|
||||
doc = new Y.Doc()
|
||||
const ytext = doc.getText("codemirror")
|
||||
const ytext = doc.getText('codemirror')
|
||||
awareness = new awarenessProtocol.Awareness(doc)
|
||||
|
||||
// ★ 先把内容写进 ytext,再挂 yCollab。yCollab 挂上去**不会**拿 ytext 覆盖编辑器,
|
||||
@@ -75,7 +73,7 @@ export function useCollabDoc() {
|
||||
// 清空会顺着 v-model 写回 codeStore,但协作中的老师不存草稿(ProblemEditor 的 persistCode),
|
||||
// 结束后 loadCode 会把他的草稿读回来
|
||||
if (seedContent === null && editorView.state.doc.length) {
|
||||
editorView.dispatch({ changes: { from: 0, to: editorView.state.doc.length, insert: "" } })
|
||||
editorView.dispatch({ changes: { from: 0, to: editorView.state.doc.length, insert: '' } })
|
||||
}
|
||||
|
||||
const send = (build: (encoder: any) => void) => {
|
||||
@@ -90,49 +88,39 @@ export function useCollabDoc() {
|
||||
if (messageType === MESSAGE_SYNC) {
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(encoder, MESSAGE_SYNC)
|
||||
syncProtocol.readSyncMessage(decoder, encoder, doc, "remote")
|
||||
syncProtocol.readSyncMessage(decoder, encoder, doc, 'remote')
|
||||
// 只有需要回话时才发(readSyncMessage 可能什么都没写)
|
||||
if (encoding.length(encoder) > 1) {
|
||||
collabStore.sendBinary(encoding.toUint8Array(encoder))
|
||||
}
|
||||
} else if (messageType === MESSAGE_AWARENESS) {
|
||||
awarenessProtocol.applyAwarenessUpdate(
|
||||
awareness,
|
||||
decoding.readVarUint8Array(decoder),
|
||||
"remote",
|
||||
)
|
||||
awarenessProtocol.applyAwarenessUpdate(awareness, decoding.readVarUint8Array(decoder), 'remote')
|
||||
}
|
||||
})
|
||||
|
||||
const onDocUpdate = (update: Uint8Array, origin: any) => {
|
||||
if (origin === "remote") return
|
||||
if (origin === 'remote') return
|
||||
send((encoder) => {
|
||||
encoding.writeVarUint(encoder, MESSAGE_SYNC)
|
||||
syncProtocol.writeUpdate(encoder, update)
|
||||
})
|
||||
}
|
||||
doc.on("update", onDocUpdate)
|
||||
detachDocUpdate = () => doc?.off("update", onDocUpdate)
|
||||
doc.on('update', onDocUpdate)
|
||||
detachDocUpdate = () => doc?.off('update', onDocUpdate)
|
||||
|
||||
const onAwarenessUpdate = (
|
||||
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
|
||||
origin: any,
|
||||
) => {
|
||||
if (origin === "remote") return
|
||||
const onAwarenessUpdate = ({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, origin: any) => {
|
||||
if (origin === 'remote') return
|
||||
const changed = added.concat(updated, removed)
|
||||
send((encoder) => {
|
||||
encoding.writeVarUint(encoder, MESSAGE_AWARENESS)
|
||||
encoding.writeVarUint8Array(
|
||||
encoder,
|
||||
awarenessProtocol.encodeAwarenessUpdate(awareness, changed),
|
||||
)
|
||||
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(awareness, changed))
|
||||
})
|
||||
}
|
||||
awareness.on("update", onAwarenessUpdate)
|
||||
detachAwarenessUpdate = () => awareness?.off("update", onAwarenessUpdate)
|
||||
awareness.on('update', onAwarenessUpdate)
|
||||
detachAwarenessUpdate = () => awareness?.off('update', onAwarenessUpdate)
|
||||
|
||||
awareness.setLocalStateField("user", {
|
||||
name: userStore.user?.username ?? "匿名",
|
||||
awareness.setLocalStateField('user', {
|
||||
name: userStore.user?.username ?? '匿名',
|
||||
color: userStore.isTeacherOrAbove ? TEACHER_COLOR : STUDENT_COLOR,
|
||||
})
|
||||
|
||||
@@ -148,10 +136,7 @@ export function useCollabDoc() {
|
||||
})
|
||||
send((encoder) => {
|
||||
encoding.writeVarUint(encoder, MESSAGE_AWARENESS)
|
||||
encoding.writeVarUint8Array(
|
||||
encoder,
|
||||
awarenessProtocol.encodeAwarenessUpdate(awareness, [doc.clientID]),
|
||||
)
|
||||
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(awareness, [doc.clientID]))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -167,7 +152,7 @@ export function useCollabDoc() {
|
||||
try {
|
||||
view.dispatch({ effects: compartment.reconfigure([]) })
|
||||
} catch (error) {
|
||||
console.warn("移除协同编辑扩展失败:", error)
|
||||
console.warn('移除协同编辑扩展失败:', error)
|
||||
}
|
||||
view = null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { useConfigWebSocket, type ConfigUpdate } from "shared/composables/websocket"
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { useConfigWebSocket, type ConfigUpdate } from 'shared/composables/websocket'
|
||||
|
||||
/**
|
||||
* 收 /ws/config 的站点配置广播,写进 configStore —— 超管改完配置,所有开着页面
|
||||
@@ -18,7 +18,7 @@ export function useConfigUpdate() {
|
||||
if (!(data.key in configStore.config)) return
|
||||
;(configStore.config as unknown as Record<string, unknown>)[data.key] = data.value
|
||||
// getConfig() 里也是这么设的,站点改名后标签页跟着变,别只更新页面里那份
|
||||
if (data.key === "websiteName") document.title = data.value
|
||||
if (data.key === 'websiteName') document.title = data.value
|
||||
}
|
||||
|
||||
// 初始化 WebSocket - handler 会在 onMounted 时自动添加
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useDark } from "@vueuse/core"
|
||||
import { useDark } from '@vueuse/core'
|
||||
|
||||
/**
|
||||
* 圆环从哪儿开始扩散。正常点击就用指针落点;键盘触发(Enter / 空格)时浏览器给的
|
||||
@@ -29,10 +29,7 @@ export function useDarkTransition() {
|
||||
const { x, y } = revealOrigin(event)
|
||||
// 半径要取到**最远**那个角的距离。用 hypot(x, y) 只覆盖到左上角,
|
||||
// 点在偏左上时右下角会有一块旧画面等圆环扩过去,看着像是没刷新。
|
||||
const radius = Math.hypot(
|
||||
Math.max(x, window.innerWidth - x),
|
||||
Math.max(y, window.innerHeight - y),
|
||||
)
|
||||
const radius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y))
|
||||
document
|
||||
.startViewTransition(() => {
|
||||
isDark.value = !isDark.value
|
||||
@@ -44,8 +41,8 @@ export function useDarkTransition() {
|
||||
},
|
||||
{
|
||||
duration: 400,
|
||||
easing: "ease-in-out",
|
||||
pseudoElement: "::view-transition-new(root)",
|
||||
easing: 'ease-in-out',
|
||||
pseudoElement: '::view-transition-new(root)',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getCurrentScope, onScopeDispose, watch, type Ref } from "vue"
|
||||
import { useDocumentVisibility } from "@vueuse/core"
|
||||
import type { ConnectionStatus } from "shared/composables/websocket"
|
||||
import { getCurrentScope, onScopeDispose, watch, type Ref } from 'vue'
|
||||
import { useDocumentVisibility } from '@vueuse/core'
|
||||
import type { ConnectionStatus } from 'shared/composables/websocket'
|
||||
|
||||
/** 交上去之后先等 WebSocket 这么久,再开始兜底 */
|
||||
const GRACE = 5000
|
||||
@@ -28,10 +28,7 @@ const MAX_ERRORS = 5
|
||||
* 常事,原来一次失败就永久停掉,之后要是又丢了推送就一直转圈),连着失败
|
||||
* MAX_ERRORS 次才放弃,别对着一个坏掉的接口一直刷。
|
||||
*/
|
||||
export function useFallbackPoll(options: {
|
||||
status: Ref<ConnectionStatus>
|
||||
poll: () => Promise<boolean>
|
||||
}) {
|
||||
export function useFallbackPoll(options: { status: Ref<ConnectionStatus>; poll: () => Promise<boolean> }) {
|
||||
const visibility = useDocumentVisibility()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let active = false
|
||||
@@ -40,7 +37,7 @@ export function useFallbackPoll(options: {
|
||||
let lastPolledAt = 0
|
||||
let errors = 0
|
||||
|
||||
const healthy = () => confirmed && options.status.value === "connected"
|
||||
const healthy = () => confirmed && options.status.value === 'connected'
|
||||
|
||||
function schedule(delay: number) {
|
||||
clearTimeout(timer)
|
||||
@@ -49,7 +46,7 @@ export function useFallbackPoll(options: {
|
||||
|
||||
async function tick() {
|
||||
if (!active) return
|
||||
if (visibility.value === "hidden") {
|
||||
if (visibility.value === 'hidden') {
|
||||
schedule(SLOW)
|
||||
return
|
||||
}
|
||||
@@ -66,7 +63,7 @@ export function useFallbackPoll(options: {
|
||||
}
|
||||
errors = 0
|
||||
} catch (error) {
|
||||
console.error("[FallbackPoll] 轮询失败:", error)
|
||||
console.error('[FallbackPoll] 轮询失败:', error)
|
||||
if (++errors >= MAX_ERRORS) {
|
||||
stop()
|
||||
return
|
||||
@@ -106,7 +103,7 @@ export function useFallbackPoll(options: {
|
||||
// 本来就没确认的不管:连不上时每次重连都要走一遍 connecting → error → disconnected,
|
||||
// 在这里重置的话退避永远退不上去
|
||||
watch(options.status, (status) => {
|
||||
if (!active || !confirmed || status === "connected") return
|
||||
if (!active || !confirmed || status === 'connected') return
|
||||
confirmed = false
|
||||
fastDelay = MIN_FAST
|
||||
schedule(MIN_FAST)
|
||||
@@ -114,7 +111,7 @@ export function useFallbackPoll(options: {
|
||||
|
||||
// 切回前台:WebSocket 还没确认的话别等满 20 秒
|
||||
watch(visibility, (value) => {
|
||||
if (active && value === "visible" && !healthy()) schedule(MIN_FAST)
|
||||
if (active && value === 'visible' && !healthy()) schedule(MIN_FAST)
|
||||
})
|
||||
|
||||
if (getCurrentScope()) onScopeDispose(stop)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { onMounted, ref } from "vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { parseTime } from 'utils/functions'
|
||||
|
||||
/**
|
||||
* 课堂看板的「请假」:老师手动标记今天不在的学生,看板的人数、进度、「还没交过」都不算他们。
|
||||
@@ -21,12 +21,12 @@ export function useLeaveStudents(storageKey: string) {
|
||||
type Marks = Record<string, string>
|
||||
|
||||
function today() {
|
||||
return parseTime(new Date(), "YYYY-MM-DD")
|
||||
return parseTime(new Date(), 'YYYY-MM-DD')
|
||||
}
|
||||
|
||||
function load(): Marks {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(storageKey) ?? "{}")
|
||||
return JSON.parse(localStorage.getItem(storageKey) ?? '{}')
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
@@ -53,9 +53,7 @@ export function useLeaveStudents(storageKey: string) {
|
||||
function setLeave(usernames: string[], selected: string[]) {
|
||||
const scope = new Set(usernames)
|
||||
const day = today()
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(marks.value).filter(([name]) => !scope.has(name)),
|
||||
)
|
||||
const next = Object.fromEntries(Object.entries(marks.value).filter(([name]) => !scope.has(name)))
|
||||
for (const name of selected) next[name] = day
|
||||
marks.value = next
|
||||
save(next)
|
||||
@@ -67,15 +65,13 @@ export function useLeaveStudents(storageKey: string) {
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
localStorage.removeItem("oj_hidden_students")
|
||||
localStorage.removeItem('oj_hidden_students')
|
||||
} catch {
|
||||
// 读写不了 localStorage 的环境,没什么可清的
|
||||
}
|
||||
// 把不是今天的清掉再落一次盘,否则这张表只增不减
|
||||
const day = today()
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(marks.value).filter(([, marked]) => marked === day),
|
||||
)
|
||||
const cleaned = Object.fromEntries(Object.entries(marks.value).filter(([, marked]) => marked === day))
|
||||
marks.value = cleaned
|
||||
save(cleaned)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ref } from "vue"
|
||||
import { createDiscreteApi } from "naive-ui"
|
||||
import { errorCode, errorMessage, GLOBALLY_HANDLED_CODES } from "utils/api"
|
||||
import { ref } from 'vue'
|
||||
import { createDiscreteApi } from 'naive-ui'
|
||||
import { errorCode, errorMessage, GLOBALLY_HANDLED_CODES } from 'utils/api'
|
||||
|
||||
const { message } = createDiscreteApi(["message"])
|
||||
const { message } = createDiscreteApi(['message'])
|
||||
|
||||
/**
|
||||
* 列表页的取数:转圈、出错提示、旧请求晚回来不盖新结果。
|
||||
@@ -33,8 +33,7 @@ export function useListLoader() {
|
||||
} catch (error) {
|
||||
if (mine !== seq) return undefined
|
||||
// 登录过期、没权限这几种全局拦截器已经提示过了
|
||||
if (!GLOBALLY_HANDLED_CODES.includes(errorCode(error) ?? ""))
|
||||
message.error(errorMessage(error, "列表没加载出来,刷新一下再试"))
|
||||
if (!GLOBALLY_HANDLED_CODES.includes(errorCode(error) ?? '')) message.error(errorMessage(error, '列表没加载出来,刷新一下再试'))
|
||||
return undefined
|
||||
} finally {
|
||||
if (mine === seq) loading.value = false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { nextTick, reactive, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { filterEmptyValue } from "utils/functions"
|
||||
import { nextTick, reactive, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { filterEmptyValue } from 'utils/functions'
|
||||
|
||||
export interface PaginationQuery {
|
||||
page: number
|
||||
@@ -31,16 +31,10 @@ export interface UsePaginationOptions {
|
||||
* @param options 配置选项
|
||||
*/
|
||||
export function usePagination<T extends Record<string, any>>(
|
||||
initialQuery: Omit<T, "page" | "limit"> = {} as Omit<T, "page" | "limit">,
|
||||
initialQuery: Omit<T, 'page' | 'limit'> = {} as Omit<T, 'page' | 'limit'>,
|
||||
options: UsePaginationOptions = {},
|
||||
) {
|
||||
const {
|
||||
defaultLimit = 10,
|
||||
defaultPage = 1,
|
||||
resetPageOnChange = true,
|
||||
extraQuery,
|
||||
replace,
|
||||
} = options
|
||||
const { defaultLimit = 10, defaultPage = 1, resetPageOnChange = true, extraQuery, replace } = options
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -77,13 +71,12 @@ export function usePagination<T extends Record<string, any>>(
|
||||
// 不能留着上一个地址的值:后退回 /problem 页面还停在上一个知识点。
|
||||
// 和 clearQuery 一样清成空值 —— initialQuery 是进页面时从地址读的,不一定是空的
|
||||
const initialValue = initialQuery[key]
|
||||
writable[key] =
|
||||
typeof initialValue === "boolean" ? false : typeof initialValue === "number" ? 0 : ""
|
||||
writable[key] = typeof initialValue === 'boolean' ? false : typeof initialValue === 'number' ? 0 : ''
|
||||
} else {
|
||||
// 处理不同类型的参数
|
||||
if (typeof initialQuery[key] === "boolean") {
|
||||
writable[key] = value === "1" || value === "true"
|
||||
} else if (typeof initialQuery[key] === "number") {
|
||||
if (typeof initialQuery[key] === 'boolean') {
|
||||
writable[key] = value === '1' || value === 'true'
|
||||
} else if (typeof initialQuery[key] === 'number') {
|
||||
writable[key] = parseInt(<string>value) || initialQuery[key]
|
||||
} else {
|
||||
writable[key] = <string>value || initialQuery[key]
|
||||
@@ -101,15 +94,14 @@ export function usePagination<T extends Record<string, any>>(
|
||||
JSON.stringify(
|
||||
Object.entries(q)
|
||||
.filter(([key, value]) => {
|
||||
if (key === "page") return String(value) !== String(defaultPage)
|
||||
if (key === "limit") return String(value) !== String(defaultLimit)
|
||||
return value != null && value !== ""
|
||||
if (key === 'page') return String(value) !== String(defaultPage)
|
||||
if (key === 'limit') return String(value) !== String(defaultLimit)
|
||||
return value != null && value !== ''
|
||||
})
|
||||
.map(([key, value]) => [key, String(value)])
|
||||
.sort(),
|
||||
)
|
||||
const navigate =
|
||||
replace?.() || normalized(route.query) === normalized(newQuery) ? router.replace : router.push
|
||||
const navigate = replace?.() || normalized(route.query) === normalized(newQuery) ? router.replace : router.push
|
||||
navigate({
|
||||
path: route.path,
|
||||
query: newQuery,
|
||||
@@ -125,11 +117,11 @@ export function usePagination<T extends Record<string, any>>(
|
||||
function clearQuery() {
|
||||
Object.keys(initialQuery).forEach((key) => {
|
||||
const initialValue = initialQuery[key]
|
||||
if (typeof initialValue === "string") {
|
||||
writable[key] = ""
|
||||
} else if (typeof initialValue === "boolean") {
|
||||
if (typeof initialValue === 'string') {
|
||||
writable[key] = ''
|
||||
} else if (typeof initialValue === 'boolean') {
|
||||
writable[key] = false
|
||||
} else if (typeof initialValue === "number") {
|
||||
} else if (typeof initialValue === 'number') {
|
||||
writable[key] = 0
|
||||
} else {
|
||||
writable[key] = initialValue
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getProblemList } from "shared/api"
|
||||
import { getProblemList } from 'shared/api'
|
||||
|
||||
/**
|
||||
* 按题号直达。课上老师报一个题号、全班去找,是非比赛提交最主要的来路,
|
||||
@@ -13,14 +13,14 @@ export function useProblemJump() {
|
||||
|
||||
async function jump(input: string) {
|
||||
const keyword = input.trim()
|
||||
if (!keyword) return router.push("/problem")
|
||||
if (!keyword) return router.push('/problem')
|
||||
jumping.value = true
|
||||
// 查询失败就当没对上,照样去列表搜,别让一次网络抖动把回车吞掉
|
||||
const res = await getProblemList(0, 20, { keyword }).catch(() => null)
|
||||
jumping.value = false
|
||||
const exact = res?.results.find((row) => row._id.toLowerCase() === keyword.toLowerCase())
|
||||
if (exact) return router.push(`/problem/${exact._id}`)
|
||||
return router.push({ path: "/problem", query: { keyword } })
|
||||
return router.push({ path: '/problem', query: { keyword } })
|
||||
}
|
||||
|
||||
return { jump, jumping }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useDark } from "@vueuse/core"
|
||||
import { computed } from "vue"
|
||||
import { RARITY_TEXT_COLOR } from "utils/constants"
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { RARITY_TEXT_COLOR } from 'utils/constants'
|
||||
|
||||
/**
|
||||
* 成就稀有度的文字配色,跟随明暗主题切换。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
import { useCollabStore } from 'shared/store/collab'
|
||||
|
||||
/**
|
||||
* 老师接单 / 主动打开学生的编辑器。两处入口(顶栏举手面板、课堂看板)共用。
|
||||
@@ -15,7 +15,7 @@ export function useTeacherHelp() {
|
||||
|
||||
function busy() {
|
||||
if (!collabStore.room) return false
|
||||
message.warning("请先结束当前协作")
|
||||
message.warning('请先结束当前协作')
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useDark } from "@vueuse/core"
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import { useDark } from '@vueuse/core'
|
||||
|
||||
export type Tone = "success" | "error" | "warning" | "info" | "default"
|
||||
export type Tone = 'success' | 'error' | 'warning' | 'info' | 'default'
|
||||
|
||||
export function rgba(hex: string, alpha: number) {
|
||||
const value = hex.replace("#", "")
|
||||
const value = hex.replace('#', '')
|
||||
const full =
|
||||
value.length === 3
|
||||
? value
|
||||
.split("")
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join("")
|
||||
.join('')
|
||||
: value.slice(0, 6)
|
||||
const n = parseInt(full, 16)
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
|
||||
@@ -43,7 +43,7 @@ export function useTone() {
|
||||
}[kind]
|
||||
return {
|
||||
color: isDark.value ? base : pressed,
|
||||
background: kind === "default" ? t.actionColor : rgba(base, isDark.value ? 0.16 : 0.12),
|
||||
background: kind === 'default' ? t.actionColor : rgba(base, isDark.value ? 0.16 : 0.12),
|
||||
solid: base,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,35 +6,26 @@
|
||||
* pele 的版本号锁死(不带 ^):项目 2026-10 才开,小版本也在改排版。
|
||||
*/
|
||||
|
||||
const semanticNodeClasses = [
|
||||
"startNode",
|
||||
"endNode",
|
||||
"startEnd",
|
||||
"input",
|
||||
"output",
|
||||
"process",
|
||||
"decision",
|
||||
"loop",
|
||||
]
|
||||
const semanticNodeClasses = ['startNode', 'endNode', 'startEnd', 'input', 'output', 'process', 'decision', 'loop']
|
||||
|
||||
const displayStyleId = "oj-mermaid-display-style"
|
||||
const displayStyleId = 'oj-mermaid-display-style'
|
||||
|
||||
/** 框的颜色:[class, 底色, 边框]。pele 把 `class n0 startNode` 原样挂在节点的 <g> 上 */
|
||||
const nodeColors: [string, string, string][] = [
|
||||
["startNode", "#eef8f2", "#18a058"],
|
||||
["startEnd", "#eef8f2", "#18a058"],
|
||||
["endNode", "#eef8f2", "#18a058"],
|
||||
["input", "#eef4fd", "#2f6bc4"],
|
||||
["output", "#eef4fd", "#2f6bc4"],
|
||||
["process", "#ffffff", "#7d828a"],
|
||||
["decision", "#fdf5e6", "#d08a1c"],
|
||||
["loop", "#fdf5e6", "#d08a1c"],
|
||||
["oj-node-palette-0", "#dbeafe", "#2563eb"],
|
||||
["oj-node-palette-1", "#ccfbf1", "#0d9488"],
|
||||
["oj-node-palette-2", "#ede9fe", "#7c3aed"],
|
||||
["oj-node-palette-3", "#ffe4e6", "#e11d48"],
|
||||
["oj-node-palette-4", "#fef3c7", "#d97706"],
|
||||
["oj-node-palette-5", "#dcfce7", "#16a34a"],
|
||||
['startNode', '#eef8f2', '#18a058'],
|
||||
['startEnd', '#eef8f2', '#18a058'],
|
||||
['endNode', '#eef8f2', '#18a058'],
|
||||
['input', '#eef4fd', '#2f6bc4'],
|
||||
['output', '#eef4fd', '#2f6bc4'],
|
||||
['process', '#ffffff', '#7d828a'],
|
||||
['decision', '#fdf5e6', '#d08a1c'],
|
||||
['loop', '#fdf5e6', '#d08a1c'],
|
||||
['oj-node-palette-0', '#dbeafe', '#2563eb'],
|
||||
['oj-node-palette-1', '#ccfbf1', '#0d9488'],
|
||||
['oj-node-palette-2', '#ede9fe', '#7c3aed'],
|
||||
['oj-node-palette-3', '#ffe4e6', '#e11d48'],
|
||||
['oj-node-palette-4', '#fef3c7', '#d97706'],
|
||||
['oj-node-palette-5', '#dcfce7', '#16a34a'],
|
||||
]
|
||||
|
||||
// !important 是为了压过老提交里 classDef 写进 style 属性的颜色,全站的图一个样
|
||||
@@ -60,7 +51,7 @@ ${nodeColors
|
||||
stroke: ${stroke} !important;
|
||||
}`,
|
||||
)
|
||||
.join("")}
|
||||
.join('')}
|
||||
|
||||
.oj-mermaid-flowchart .pele-node text {
|
||||
fill: #0f172a !important;
|
||||
@@ -81,28 +72,26 @@ ${nodeColors
|
||||
}
|
||||
`
|
||||
|
||||
const svgNamespace = "http://www.w3.org/2000/svg"
|
||||
const svgNamespace = 'http://www.w3.org/2000/svg'
|
||||
|
||||
function applyFlowchartDisplayStyle(container: HTMLElement) {
|
||||
container.classList.add("oj-mermaid-surface")
|
||||
container.classList.add('oj-mermaid-surface')
|
||||
|
||||
const svg = container.querySelector("svg")
|
||||
const svg = container.querySelector('svg')
|
||||
if (!svg) return
|
||||
|
||||
svg.classList.add("oj-mermaid-flowchart")
|
||||
svg.classList.add('oj-mermaid-flowchart')
|
||||
|
||||
const nodes = Array.from(svg.querySelectorAll<SVGGElement>("g.pele-node"))
|
||||
const nodes = Array.from(svg.querySelectorAll<SVGGElement>('g.pele-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),
|
||||
)
|
||||
const hasSemanticClass = semanticNodeClasses.some((className) => node.classList.contains(className))
|
||||
if (!hasSemanticClass) {
|
||||
const label = node.querySelector(".pele-label")?.textContent?.trim() ?? ""
|
||||
const label = node.querySelector('.pele-label')?.textContent?.trim() ?? ''
|
||||
if (!labelPaletteMap.has(label)) {
|
||||
labelPaletteMap.set(label, paletteCounter % 6)
|
||||
paletteCounter++
|
||||
@@ -112,8 +101,8 @@ function applyFlowchartDisplayStyle(container: HTMLElement) {
|
||||
})
|
||||
|
||||
svg.querySelector(`#${displayStyleId}`)?.remove()
|
||||
const style = document.createElementNS(svgNamespace, "style")
|
||||
style.setAttribute("id", displayStyleId)
|
||||
const style = document.createElementNS(svgNamespace, 'style')
|
||||
style.setAttribute('id', displayStyleId)
|
||||
style.textContent = mermaidDisplayStyle
|
||||
svg.insertBefore(style, svg.firstChild)
|
||||
}
|
||||
@@ -127,14 +116,14 @@ export function useMermaid() {
|
||||
renderError.value = null
|
||||
renderSuccess.value = false
|
||||
|
||||
if (container) container.innerHTML = ""
|
||||
if (container) container.innerHTML = ''
|
||||
|
||||
if (!container || !mermaidCode?.trim()) return
|
||||
|
||||
const gen = ++renderGeneration
|
||||
try {
|
||||
// pele/lazy 按图的类型现拉对应的模块,题目里绝大多数是流程图,只拉那一块
|
||||
const { renderAsync } = await import("pele/lazy")
|
||||
const { renderAsync } = await import('pele/lazy')
|
||||
const { svg } = await renderAsync(mermaidCode, {
|
||||
// 量字宽用页面真实的字体,否则框会比字窄或宽
|
||||
fontFamily: getComputedStyle(container).fontFamily,
|
||||
@@ -148,7 +137,7 @@ export function useMermaid() {
|
||||
renderSuccess.value = true
|
||||
} catch (error) {
|
||||
if (gen !== renderGeneration) return
|
||||
renderError.value = error instanceof Error ? error.message : "流程图渲染失败,请检查代码格式"
|
||||
renderError.value = error instanceof Error ? error.message : '流程图渲染失败,请检查代码格式'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { watch } from "vue"
|
||||
import { useDocumentVisibility, useIntervalFn } from "@vueuse/core"
|
||||
import { watch } from 'vue'
|
||||
import { useDocumentVisibility, useIntervalFn } from '@vueuse/core'
|
||||
|
||||
/**
|
||||
* 定时刷新,但页面藏在后台时不拉,切回来立刻补一次。
|
||||
@@ -12,13 +12,13 @@ export function useVisibleInterval(fn: () => unknown, interval: number) {
|
||||
const visibility = useDocumentVisibility()
|
||||
const { pause, resume, isActive } = useIntervalFn(
|
||||
() => {
|
||||
if (visibility.value === "visible") fn()
|
||||
if (visibility.value === 'visible') fn()
|
||||
},
|
||||
interval,
|
||||
{ immediate: false },
|
||||
)
|
||||
watch(visibility, (value) => {
|
||||
if (value === "visible" && isActive.value) fn()
|
||||
if (value === 'visible' && isActive.value) fn()
|
||||
})
|
||||
return { pause, resume }
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import { WS_AUTH_PROTOCOL } from "@oj2/contract"
|
||||
import { createDiscreteApi } from "naive-ui"
|
||||
import { ref, getCurrentScope, onScopeDispose, type Ref } from "vue"
|
||||
import { WS_AUTH_PROTOCOL } from '@oj2/contract'
|
||||
import { createDiscreteApi } from 'naive-ui'
|
||||
import { ref, getCurrentScope, onScopeDispose, type Ref } from 'vue'
|
||||
|
||||
import { useAuthModalStore } from "shared/store/authModal"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { getAccessToken } from "utils/auth"
|
||||
import { STORAGE_KEY } from "utils/constants"
|
||||
import storage from "utils/storage"
|
||||
import type { ClassBoardCell } from "utils/types"
|
||||
import { useAuthModalStore } from 'shared/store/authModal'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { getAccessToken } from 'utils/auth'
|
||||
import { STORAGE_KEY } from 'utils/constants'
|
||||
import storage from 'utils/storage'
|
||||
import type { ClassBoardCell } from 'utils/types'
|
||||
|
||||
type BoardLatest = NonNullable<ClassBoardCell["latest"]>
|
||||
type BoardLatest = NonNullable<ClassBoardCell['latest']>
|
||||
|
||||
// 全站唯一一处,脱离 n-message-provider 也能弹 —— 强制登出跟当前挂着哪个组件无关。
|
||||
// utils/api.ts 里也是这么做的
|
||||
const { message: toast } = createDiscreteApi(["message"])
|
||||
const { message: toast } = createDiscreteApi(['message'])
|
||||
|
||||
/**
|
||||
* 服务端要求下线。两种来源:账号被管理员禁用,或者这张会话没了(在别的标签页
|
||||
@@ -29,9 +29,9 @@ function handleForceLogout(reason: string) {
|
||||
if (!userStore.isAuthed) return
|
||||
storage.remove(STORAGE_KEY.AUTHED)
|
||||
userStore.clearProfile()
|
||||
if (reason === "account-disabled") {
|
||||
if (reason === 'account-disabled') {
|
||||
// 不能弹登录框:账号已经禁用,登进去还是被拒,会陷进「弹框 → 登录 → 又弹框」
|
||||
toast.error("账号已被禁用,请联系老师")
|
||||
toast.error('账号已被禁用,请联系老师')
|
||||
return
|
||||
}
|
||||
useAuthModalStore().openLoginModal()
|
||||
@@ -40,7 +40,7 @@ function handleForceLogout(reason: string) {
|
||||
/**
|
||||
* WebSocket 连接状态
|
||||
*/
|
||||
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
/**
|
||||
* WebSocket 消息类型
|
||||
@@ -66,7 +66,7 @@ const HEARTBEAT_INTERVAL = 30_000
|
||||
|
||||
/** 按当前页面的协议与 host 拼通道地址。后端只认 /ws/submissions、/ws/config、/ws/collab */
|
||||
function channelUrl(path: string) {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${protocol}//${window.location.host}${path}`
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
/** 正在等 access token、连接还没建出来,见 connect() */
|
||||
protected opening = false
|
||||
|
||||
public status: Ref<ConnectionStatus> = ref<ConnectionStatus>("disconnected")
|
||||
public status: Ref<ConnectionStatus> = ref<ConnectionStatus>('disconnected')
|
||||
|
||||
constructor(path: string) {
|
||||
this.url = channelUrl(path)
|
||||
@@ -112,15 +112,12 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
this.closedByUser = false
|
||||
this.clearReconnectTimer()
|
||||
|
||||
if (
|
||||
this.ws &&
|
||||
(this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.bindReviveListeners()
|
||||
this.status.value = "connecting"
|
||||
this.status.value = 'connecting'
|
||||
if (this.opening) return
|
||||
|
||||
// 握手要带 access token,快过期了得先换一张 —— 所以真正建连接是异步的
|
||||
@@ -143,11 +140,11 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
// 浏览器的 WebSocket 设不了请求头,token 借子协议带过去,见契约 WS_AUTH_PROTOCOL
|
||||
const ws = new WebSocket(this.url, token ? [WS_AUTH_PROTOCOL, token] : undefined)
|
||||
this.ws = ws
|
||||
ws.binaryType = "arraybuffer"
|
||||
ws.binaryType = 'arraybuffer'
|
||||
|
||||
ws.onopen = () => {
|
||||
if (ws !== this.ws) return
|
||||
this.status.value = "connected"
|
||||
this.status.value = 'connected'
|
||||
this.reconnectAttempts = 0
|
||||
console.log(`[WebSocket] 连接成功: ${this.url}`)
|
||||
this.startHeartbeat()
|
||||
@@ -159,7 +156,7 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
|
||||
// Yjs 这类二进制帧不是 JSON,交给子类。基类的 pong / force_logout 都是文本帧,
|
||||
// 不会走到这条路径上
|
||||
if (typeof event.data !== "string") {
|
||||
if (typeof event.data !== 'string') {
|
||||
this.onBinary(event.data as ArrayBuffer)
|
||||
return
|
||||
}
|
||||
@@ -168,31 +165,31 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
const data = JSON.parse(event.data) as T
|
||||
|
||||
// 处理心跳响应
|
||||
if (data.type === "pong") {
|
||||
if (data.type === 'pong') {
|
||||
return
|
||||
}
|
||||
|
||||
// 服务端要求下线。和 pong 一样是协议层的事,不该让每个业务 handler
|
||||
// 各自认一遍 —— 而且此刻多半根本没有能处理它的 handler 挂着
|
||||
if (data.type === "force_logout") {
|
||||
if (data.type === 'force_logout') {
|
||||
// 必须主动断,否则服务端断开后这条连接会照常自动重连,然后一路 401
|
||||
// 撞到退避上限 —— 正是这条机制要消掉的浪费
|
||||
this.disconnect()
|
||||
handleForceLogout(String(data.reason ?? ""))
|
||||
handleForceLogout(String(data.reason ?? ''))
|
||||
return
|
||||
}
|
||||
|
||||
// 调用消息处理钩子
|
||||
this.onMessage(data)
|
||||
} catch (error) {
|
||||
console.error("[WebSocket] 解析消息失败:", error)
|
||||
console.error('[WebSocket] 解析消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
if (ws !== this.ws) return
|
||||
console.error("[WebSocket] 连接错误:", error)
|
||||
this.status.value = "error"
|
||||
console.error('[WebSocket] 连接错误:', error)
|
||||
this.status.value = 'error'
|
||||
this.onError(error)
|
||||
}
|
||||
|
||||
@@ -202,14 +199,14 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
if (ws !== this.ws) return
|
||||
this.ws = null
|
||||
console.log(`[WebSocket] 连接关闭: code=${event.code}, reason=${event.reason}`)
|
||||
this.status.value = "disconnected"
|
||||
this.status.value = 'disconnected'
|
||||
this.stopHeartbeat()
|
||||
this.onDisconnected(event)
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create WebSocket connection:", error)
|
||||
this.status.value = "error"
|
||||
console.error('Failed to create WebSocket connection:', error)
|
||||
this.status.value = 'error'
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
@@ -223,10 +220,7 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
if (this.reconnectTimer !== null) return
|
||||
|
||||
this.reconnectAttempts++
|
||||
const base = Math.min(
|
||||
RECONNECT_BASE_DELAY * 2 ** (this.reconnectAttempts - 1),
|
||||
RECONNECT_MAX_DELAY,
|
||||
)
|
||||
const base = Math.min(RECONNECT_BASE_DELAY * 2 ** (this.reconnectAttempts - 1), RECONNECT_MAX_DELAY)
|
||||
const delay = Math.round(base * (0.5 + Math.random() * 0.5))
|
||||
console.log(`[WebSocket] 将在 ${delay}ms 后重连 (第 ${this.reconnectAttempts} 次)`)
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
@@ -248,13 +242,10 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
*/
|
||||
protected readonly revive = () => {
|
||||
if (this.closedByUser) return
|
||||
if (
|
||||
this.ws &&
|
||||
(this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return
|
||||
}
|
||||
if (document.visibilityState === "hidden") return
|
||||
if (document.visibilityState === 'hidden') return
|
||||
if (navigator.onLine === false) return
|
||||
this.clearReconnectTimer()
|
||||
this.reconnectAttempts = 0
|
||||
@@ -264,15 +255,15 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
protected bindReviveListeners() {
|
||||
if (this.reviveBound) return
|
||||
this.reviveBound = true
|
||||
window.addEventListener("online", this.revive)
|
||||
document.addEventListener("visibilitychange", this.revive)
|
||||
window.addEventListener('online', this.revive)
|
||||
document.addEventListener('visibilitychange', this.revive)
|
||||
}
|
||||
|
||||
protected unbindReviveListeners() {
|
||||
if (!this.reviveBound) return
|
||||
this.reviveBound = false
|
||||
window.removeEventListener("online", this.revive)
|
||||
document.removeEventListener("visibilitychange", this.revive)
|
||||
window.removeEventListener('online', this.revive)
|
||||
document.removeEventListener('visibilitychange', this.revive)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,7 +282,7 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
const ws = this.ws
|
||||
this.ws = null
|
||||
if (ws) ws.close()
|
||||
this.status.value = "disconnected"
|
||||
this.status.value = 'disconnected'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -379,7 +370,7 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
* 发送心跳包
|
||||
*/
|
||||
protected sendHeartbeat() {
|
||||
this.send({ type: "ping", timestamp: Date.now() })
|
||||
this.send({ type: 'ping', timestamp: Date.now() })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -424,7 +415,7 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
* 服务端收到 subscribe 会回一份当前状态,掉线期间错过的那条推送就是靠这次
|
||||
* 重放补回来的。不重新订阅,重连后就只收得到「将来」的事件,可结果已经是过去式了。
|
||||
*/
|
||||
private subscribedId = ""
|
||||
private subscribedId = ''
|
||||
|
||||
/**
|
||||
* 订阅特定提交的更新。连接没就绪也可以调,连上后会自动补发 ——
|
||||
@@ -440,11 +431,11 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
|
||||
/** 结果已经拿到,重连后不必再问一遍 */
|
||||
unsubscribe() {
|
||||
this.subscribedId = ""
|
||||
this.subscribedId = ''
|
||||
}
|
||||
|
||||
private sendSubscribe(submissionId: string) {
|
||||
return this.send({ type: "subscribe", submissionId })
|
||||
return this.send({ type: 'subscribe', submissionId })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,7 +455,7 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
try {
|
||||
handler(data)
|
||||
} catch (error) {
|
||||
console.error("Error in message handler:", error)
|
||||
console.error('Error in message handler:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -474,10 +465,10 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
* 提交状态更新的数据类型
|
||||
*/
|
||||
export interface SubmissionUpdate extends WebSocketMessage {
|
||||
type: "submission_update"
|
||||
type: 'submission_update'
|
||||
submissionId: string
|
||||
result: number
|
||||
status: "pending" | "judging" | "finished" | "error"
|
||||
status: 'pending' | 'judging' | 'finished' | 'error'
|
||||
score?: number
|
||||
}
|
||||
|
||||
@@ -525,26 +516,23 @@ function useChannel<T extends WebSocketMessage>(path: string, handler?: MessageH
|
||||
* 提交状态更新的数据类型
|
||||
*/
|
||||
export interface SubmissionUpdate extends WebSocketMessage {
|
||||
type: "submission_update"
|
||||
type: 'submission_update'
|
||||
submissionId: string
|
||||
result: number
|
||||
status: "pending" | "judging" | "finished" | "error"
|
||||
status: 'pending' | 'judging' | 'finished' | 'error'
|
||||
score?: number
|
||||
}
|
||||
|
||||
/** 判题进度。subscribe(submissionId) 认领,断线重连会自动补订阅 */
|
||||
export function useSubmissionWebSocket(handler?: MessageHandler<SubmissionUpdate>) {
|
||||
return useChannel<SubmissionUpdate>("/ws/submissions", handler)
|
||||
return useChannel<SubmissionUpdate>('/ws/submissions', handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程图评分更新消息类型
|
||||
*/
|
||||
export interface FlowchartEvaluationUpdate extends WebSocketMessage {
|
||||
type:
|
||||
| "flowchart_evaluation_completed"
|
||||
| "flowchart_evaluation_failed"
|
||||
| "flowchart_evaluation_update"
|
||||
type: 'flowchart_evaluation_completed' | 'flowchart_evaluation_failed' | 'flowchart_evaluation_update'
|
||||
submissionId: string
|
||||
score?: number
|
||||
grade?: string
|
||||
@@ -559,14 +547,14 @@ export interface FlowchartEvaluationUpdate extends WebSocketMessage {
|
||||
* 服务端按 submissionId 分辨是代码提交还是流程图,这里只是换一套消息类型。
|
||||
*/
|
||||
export function useFlowchartWebSocket(handler?: MessageHandler<FlowchartEvaluationUpdate>) {
|
||||
return useChannel<FlowchartEvaluationUpdate>("/ws/submissions", handler)
|
||||
return useChannel<FlowchartEvaluationUpdate>('/ws/submissions', handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置更新消息类型
|
||||
*/
|
||||
export interface ConfigUpdate extends WebSocketMessage {
|
||||
type: "config_update"
|
||||
type: 'config_update'
|
||||
key: string
|
||||
value: any
|
||||
}
|
||||
@@ -578,7 +566,7 @@ export interface ConfigUpdate extends WebSocketMessage {
|
||||
* 由后端广播给所有人。
|
||||
*/
|
||||
export function useConfigWebSocket(handler?: MessageHandler<ConfigUpdate>) {
|
||||
return useChannel<ConfigUpdate>("/ws/config", handler)
|
||||
return useChannel<ConfigUpdate>('/ws/config', handler)
|
||||
}
|
||||
|
||||
export interface CollabRequestItem {
|
||||
@@ -590,19 +578,19 @@ export interface CollabRequestItem {
|
||||
/** 他这道题最后一次代码提交(和看板格子的 latest 同形),面板按它写错法;没交过为 null */
|
||||
latest: BoardLatest | null
|
||||
createdAt: number
|
||||
status: "pending" | "active"
|
||||
status: 'pending' | 'active'
|
||||
teacherName: string | null
|
||||
}
|
||||
|
||||
export interface CollabMessage extends WebSocketMessage {
|
||||
type:
|
||||
| "requests"
|
||||
| "help_status"
|
||||
| "room_open"
|
||||
| 'requests'
|
||||
| 'help_status'
|
||||
| 'room_open'
|
||||
// 协作期间学生换了语言,只发给教师端
|
||||
| "room_language"
|
||||
| "room_closed"
|
||||
| "error"
|
||||
| 'room_language'
|
||||
| 'room_closed'
|
||||
| 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -629,7 +617,7 @@ export class CollabWebSocket extends BaseWebSocket<CollabMessage> {
|
||||
private pendingBinary: ArrayBuffer[] = []
|
||||
|
||||
constructor() {
|
||||
super("/ws/collab")
|
||||
super('/ws/collab')
|
||||
}
|
||||
|
||||
setBinaryHandler(handler: ((data: ArrayBuffer) => void) | null) {
|
||||
|
||||
Reference in new issue
Block a user