style(格式化): 改成单引号、一行最长 160,全仓重新格式化
Deploy / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 13:52:24 +08:00
1 parent 530e1a3d5d
commit 195069450f
480 files changed
+15507 -24650

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
import { streamAI } from "utils/stream"
import { streamAI } from 'utils/stream'
/**
* 组件里发 AI 流式请求:同一时刻只跑一条(再点一次会先掐掉上一条),组件卸载时
+3 -3
View File
@@ -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 },
})
+30 -45
View File
@@ -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
+17 -25
View File
@@ -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 }
+3 -3
View File
@@ -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
}
+7 -7
View File
@@ -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,
}
}
+29 -40
View File
@@ -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 }
}
+60 -72
View File
@@ -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) {