update
Deploy / deploy (push) Canceled after 0s

This commit is contained in:
xuyue committed 2026-09-27 19:36:28 +08:00
1 parent 23844ae301
commit d25b5dbe6d
268 files changed
+3684 -8861

No files matched your search

+2 -8
View File
@@ -7,11 +7,7 @@ export function login(data: { username: string; password: string }) {
return api.post("auth/login", data)
}
export function signup(data: {
username: string
email: string
password: string
}) {
export function signup(data: { username: string; email: string; password: string }) {
return api.post("users", data)
}
@@ -19,9 +15,7 @@ export function logout() {
return api.delete("auth/session")
}
export async function getProfile(
username: string = "",
): Promise<Profile | null> {
export async function getProfile(username: string = ""): Promise<Profile | null> {
const endpoint = username ? `profiles/${encodeURIComponent(username)}` : "me"
const response = await api.get<unknown>(endpoint)
if (response === null) return null
@@ -20,20 +20,8 @@ const kind = computed(() => {
</script>
<template>
<img
v-if="kind === 'image'"
:src="icon"
:width="size"
:height="size"
class="image"
alt=""
/>
<Icon
v-else-if="kind === 'iconify'"
:icon="icon"
:width="size"
:height="size"
/>
<img v-if="kind === 'image'" :src="icon" :width="size" :height="size" class="image" alt="" />
<Icon v-else-if="kind === 'iconify'" :icon="icon" :width="size" :height="size" />
<span v-else class="fallback" :style="{ fontSize: `${size}px` }">
{{ icon }}
</span>
+4 -14
View File
@@ -14,9 +14,7 @@
</n-flex>
<template v-if="!isMobile">
<n-button text @click="goICP">浙ICP备2023044109号-1</n-button>
<n-button text @click="goPublicSecurity">
浙公网安备33100402331786号
</n-button>
<n-button text @click="goPublicSecurity"> 浙公网安备33100402331786号 </n-button>
</template>
</n-flex>
</template>
@@ -26,9 +24,7 @@ import { zonedYear } from "utils/functions"
const route = useRoute()
const { isMobile } = useBreakpoints()
const hiddenICP = computed(() =>
["problem", "contest problem"].includes(route.name as string),
)
const hiddenICP = computed(() => ["problem", "contest problem"].includes(route.name as string))
// 版权年份也走东八区:站内不留任何一处按浏览器时区取时间部件的代码,
// 免得下一个人照着抄
@@ -40,17 +36,11 @@ function goICP() {
}
function goCC() {
window.open(
"https://creativecommons.org/licenses/by-nc/4.0/deed.zh-hans",
"_blank",
)
window.open("https://creativecommons.org/licenses/by-nc/4.0/deed.zh-hans", "_blank")
}
function goPublicSecurity() {
window.open(
"https://beian.mps.gov.cn/#/query/webSearch?code=33100402331786",
"_blank",
)
window.open("https://beian.mps.gov.cn/#/query/webSearch?code=33100402331786", "_blank")
}
</script>
<style scoped>
@@ -5,11 +5,7 @@ import { sql, SQLite } from "@codemirror/lang-sql"
import { bracketMatching } from "@codemirror/language"
import { Codemirror } from "vue-codemirror"
import type { Extension } from "@codemirror/state"
import {
autocompletion,
closeBrackets,
completeAnyWord,
} from "@codemirror/autocomplete"
import { autocompletion, closeBrackets, completeAnyWord } from "@codemirror/autocomplete"
import type { LANGUAGE } from "utils/types"
import { oneDark } from "../themes/oneDark"
import { smoothy } from "../themes/smoothy"
@@ -39,8 +35,7 @@ const code = defineModel<string>("value")
const isDark = useDark()
const langExtension = computed(() => {
if (language === "SQL")
return sql({ dialect: SQLite, upperCaseKeywords: true })
if (language === "SQL") return sql({ dialect: SQLite, upperCaseKeywords: true })
return language === "Python" ? python() : cpp()
})
@@ -52,9 +52,7 @@ watch(
if (item.status !== "pending") continue
if (!latest || item.createdAt > latest.createdAt) latest = item
}
const text = latest
? `${latest.studentName} 求助:${latest.problemTitle}`
: "有新的求助"
const text = latest ? `${latest.studentName} 求助:${latest.problemTitle}` : "有新的求助"
// 内容传 render 函数(naive 的 content 支持),这样整条 toast 可点:
// 点一下直接开求助列表,省得再去点名字、再点菜单。
const notice = message.info(
@@ -77,8 +75,5 @@ watch(
</script>
<template>
<HelpRequestList
v-if="collabStore.isTeacher"
v-model:show="collabStore.helpPanelOpen"
/>
<HelpRequestList v-if="collabStore.isTeacher" v-model:show="collabStore.helpPanelOpen" />
</template>
@@ -9,9 +9,7 @@ interface Props {
const props = defineProps<Props>()
const isPrivate = computed(
() => props.contest.contestType === ContestType.private,
)
const isPrivate = computed(() => props.contest.contestType === ContestType.private)
</script>
<template>
@@ -87,9 +87,7 @@ let globalClickHandler: ((event: MouseEvent) => void) | null = null
// 计算属性
const nodeType = computed(() => props.data.originalType || props.type)
const nodeConfig = computed(() => getNodeTypeConfig(nodeType.value))
const displayLabel = computed(
() => props.data.customLabel || nodeConfig.value.label,
)
const displayLabel = computed(() => props.data.customLabel || nodeConfig.value.label)
// 事件处理器
const handleDelete = () => emit("delete", props.id)
@@ -164,10 +162,7 @@ const addGlobalClickHandler = () => {
if (globalClickHandler) return
globalClickHandler = (event: MouseEvent) => {
if (
isEditing.value &&
!(event.target as Element)?.closest(".custom-node")
) {
if (isEditing.value && !(event.target as Element)?.closest(".custom-node")) {
handleSaveEdit()
}
}
@@ -1,14 +1,6 @@
<template>
<div
class="node-actions"
@mouseenter="$emit('mouseenter')"
@mouseleave="$emit('mouseleave')"
>
<button
class="action-btn delete-btn"
@click.stop="$emit('delete')"
title="删除节点"
>
<div class="node-actions" @mouseenter="$emit('mouseenter')" @mouseleave="$emit('mouseleave')">
<button class="action-btn delete-btn" @click.stop="$emit('delete')" title="删除节点">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"
@@ -14,8 +14,7 @@ const onDragStart = (event: DragEvent, type: string) => {
// 隐藏浏览器默认拖影,改用 canvas 跟随预览
const emptyImg = new Image(1, 1)
emptyImg.src =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
emptyImg.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
event.dataTransfer.setDragImage(emptyImg, 0, 0)
}
@@ -44,15 +43,13 @@ const emit = defineEmits<{
// 节点类型定义 - 优化性能
const nodeTypes = computed(() =>
["start", "input", "default", "decision", "loop", "output", "end"].map(
(type) => {
const config = getNodeTypeConfig(type)
return {
type,
...config,
}
},
),
["start", "input", "default", "decision", "loop", "output", "end"].map((type) => {
const config = getNodeTypeConfig(type)
return {
type,
...config,
}
}),
)
const saveStatusTitle = computed(() => {
@@ -135,11 +132,7 @@ const saveStatusTitle = computed(() => {
<span class="btn-text">重做</span>
</button>
</div>
<button
class="action-btn clear-btn"
@click="$emit('clear')"
title="清空画布"
>
<button class="action-btn clear-btn" @click="$emit('clear')" title="清空画布">
<span class="btn-icon">🗑️</span>
<span class="btn-text">清空画布</span>
</button>
@@ -4,13 +4,7 @@ import Toolbar from "./Toolbar.vue"
import "@vue-flow/core/dist/style.css"
import "@vue-flow/core/dist/theme-default.css"
import "@vue-flow/controls/dist/style.css"
import {
useVueFlow,
VueFlow,
type Node,
type Edge,
MarkerType,
} from "@vue-flow/core"
import { useVueFlow, VueFlow, type Node, type Edge, MarkerType } from "@vue-flow/core"
import { Controls } from "@vue-flow/controls"
import { Background } from "@vue-flow/background"
@@ -49,18 +43,16 @@ const cacheKey = computed(() =>
? `flowchart-editor-data-problem-${problem.value!._id}`
: "flowchart-editor-data",
)
const {
isSaving,
lastSaved,
hasUnsavedChanges,
saveToCache,
loadFromCache,
clearCache,
} = useCache(nodes, edges, cacheKey, () => {
// 换题后画布已经被换成新题的草稿,历史必须跟着重建,
// 否则一次撤销就会把上一题的图还原到这一题里
resetHistory(nodes.value, edges.value)
})
const { isSaving, lastSaved, hasUnsavedChanges, saveToCache, loadFromCache, clearCache } = useCache(
nodes,
edges,
cacheKey,
() => {
// 换题后画布已经被换成新题的草稿,历史必须跟着重建,
// 否则一次撤销就会把上一题的图还原到这一题里
resetHistory(nodes.value, edges.value)
},
)
// 拖拽处理
const { onDragOver, onDragLeave, onDrop, isDragOver, screenDragPos } = useDnD()
@@ -85,14 +77,7 @@ const {
handleNodeUpdate,
clearCanvas,
deleteSelected,
} = useFlowOperations(
nodes,
edges,
addEdges,
removeNodes,
removeEdges,
saveState,
)
} = useFlowOperations(nodes, edges, addEdges, removeNodes, removeEdges, saveState)
const handleDrop = async (event: DragEvent) => {
const newNode = onDrop(event)
@@ -140,10 +125,7 @@ const handleClear = () => {
// 键盘事件
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
)
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement)
return
if (event.key === "Delete" || event.key === "Backspace") {
@@ -220,9 +202,7 @@ defineExpose({
class="drag-node-preview"
:style="dragPreviewStyle"
>
<span class="preview-icon">{{
getNodeTypeConfig(currentDragNodeType).icon
}}</span>
<span class="preview-icon">{{ getNodeTypeConfig(currentDragNodeType).icon }}</span>
<span>{{ getNodeTypeConfig(currentDragNodeType).label }}</span>
</div>
</Transition>
@@ -272,12 +252,7 @@ defineExpose({
orient="auto"
markerUnits="strokeWidth"
>
<path
d="M0,0 L0,6 L10,3 z"
fill="#6366f1"
stroke="#6366f1"
strokeWidth="0.5"
/>
<path d="M0,0 L0,6 L10,3 z" fill="#6366f1" stroke="#6366f1" strokeWidth="0.5" />
</marker>
</defs>
<template #node-custom="{ data, id, type }">
@@ -53,9 +53,7 @@ export function useCache(
if (storedData.value.nodes?.length || storedData.value.edges?.length) {
nodes.value = storedData.value.nodes
edges.value = storedData.value.edges
lastSaved.value = storedData.value.timestamp
? new Date(storedData.value.timestamp)
: null
lastSaved.value = storedData.value.timestamp ? new Date(storedData.value.timestamp) : null
hasUnsavedChanges.value = false
return true
}
@@ -1,10 +1,6 @@
import { ref } from "vue"
import { useVueFlow } from "@vue-flow/core"
import {
getNodeTypeConfig,
createNodeStyle,
getNodeDimensions,
} from "./useNodeStyles"
import { getNodeTypeConfig, createNodeStyle, getNodeDimensions } from "./useNodeStyles"
import { getRandomId } from "utils/functions"
// 模块级共享:当前拖拽的节点类型(Toolbar 写入,canvas 读取)
@@ -57,12 +57,7 @@ export function useFlowOperations(
const targetNode = nodes.value.find((node) => node.id === params.target)
// 自动推断标签
const autoLabel = getAutoLabel(
sourceNode,
targetNode,
params.sourceHandle,
params.targetHandle,
)
const autoLabel = getAutoLabel(sourceNode, targetNode, params.sourceHandle, params.targetHandle)
const newEdge: Edge = {
id: `edge-${getRandomId()}`,
@@ -15,10 +15,7 @@ export function useHistory() {
// 是否可以重做
const canRedo = computed(() => historyIndex.value < history.value.length - 1)
const deepCopyState = (
nodes: Node[],
edges: Edge[],
): { nodes: Node[]; edges: Edge[] } =>
const deepCopyState = (nodes: Node[], edges: Edge[]): { nodes: Node[]; edges: Edge[] } =>
// 先裁掉 vue-flow 的运行时内部字段再深拷贝:20 份快照 × 每个节点几百字节的
// handleBounds/dimensions,纯属白拷
JSON.parse(
@@ -12,11 +12,7 @@
style="width: 120px"
clearable
/>
<n-select
style="width: 120px"
v-model:value="query.duration"
:options="durationOptions"
/>
<n-select style="width: 120px" v-model:value="query.duration" :options="durationOptions" />
<n-button type="primary" @click="handleStatistics">统计</n-button>
</n-flex>
@@ -75,10 +71,7 @@
<n-gi v-if="data.personCount > 0">
<n-card title="班级完成度">
<div class="chart-container">
<Doughnut
:data="completionChartData"
:options="doughnutOptions"
/>
<Doughnut :data="completionChartData" :options="doughnutOptions" />
</div>
</n-card>
</n-gi>
@@ -119,21 +112,12 @@
<template #checked>请假隐藏中</template>
<template #unchecked>请假隐藏</template>
</n-switch>
<n-button
v-if="hiddenCount > 0"
size="small"
type="info"
@click="showAll"
>
<n-button v-if="hiddenCount > 0" size="small" type="info" @click="showAll">
恢复 {{ hiddenCount }} 位
</n-button>
</n-flex>
<n-flex size="large" align="center">
<n-gradient-text
v-if="visibleUnaccepted.length === 0"
font-size="24"
type="success"
>
<n-gradient-text v-if="visibleUnaccepted.length === 0" font-size="24" type="success">
全都完成了
</n-gradient-text>
<template v-for="item in visibleUnaccepted" :key="item.username">
@@ -158,10 +142,7 @@
import { formatISO, sub, type Duration } from "date-fns"
import type { FlowchartStatistics } from "@oj2/contract"
import { getFlowchartStatistics } from "oj/api"
import {
PANEL_DURATION_OPTIONS,
FLOWCHART_CRITERIA_ORDER,
} from "utils/constants"
import { PANEL_DURATION_OPTIONS, FLOWCHART_CRITERIA_ORDER } from "utils/constants"
import { durationFromValue } from "utils/functions"
import { useHiddenStudents } from "../composables/hiddenStudents"
import { Doughnut, Radar, Bar } from "vue-chartjs"
@@ -229,15 +210,14 @@ const data = reactive<FlowchartStatistics>({
* 「查出东西了吗」。和 StatisticsPanel 同一个道理:一节课刚开始时没有任何提交,
* 但花名册整份都在 dataUnaccepted 里,那会儿正是老师要看名单的时候。
*/
const hasResult = computed(
() => data.totalCount > 0 || data.dataUnaccepted.length > 0,
)
const hasResult = computed(() => data.totalCount > 0 || data.dataUnaccepted.length > 0)
const wordcloudCanvas = useTemplateRef<HTMLCanvasElement>("wordcloudCanvas")
let wordcloudChart: ChartJS | null = null
const { hideMode, hideStudent, showAll, isHidden, notHidden } =
useHiddenStudents("oj_hidden_students_flowchart")
const { hideMode, hideStudent, showAll, isHidden, notHidden } = useHiddenStudents(
"oj_hidden_students_flowchart",
)
const visibleUnaccepted = computed(() => data.dataUnaccepted.filter(notHidden))
@@ -245,16 +225,11 @@ const hiddenCount = computed(
() => data.dataUnaccepted.filter((item) => isHidden(item.username)).length,
)
const adjustedPersonCount = computed(() =>
Math.max(0, data.personCount - hiddenCount.value),
)
const adjustedPersonCount = computed(() => Math.max(0, data.personCount - hiddenCount.value))
const completionRate = computed(() => {
if (adjustedPersonCount.value <= 0) return "0%"
const rate = Math.min(
100,
(data.completedCount / adjustedPersonCount.value) * 100,
)
const rate = Math.min(100, (data.completedCount / adjustedPersonCount.value) * 100)
return `${Math.round(rate * 100) / 100}%`
})
@@ -283,10 +258,7 @@ const gradeChartData = computed(() => {
})
const completionChartData = computed(() => {
const uncompleted = Math.max(
0,
adjustedPersonCount.value - data.completedCount,
)
const uncompleted = Math.max(0, adjustedPersonCount.value - data.completedCount)
return {
labels: ["已完成", "未完成"],
datasets: [
@@ -310,10 +282,7 @@ const doughnutOptions = {
label(context: any) {
const label = context.label || ""
const value = context.parsed || 0
const total = context.dataset.data.reduce(
(a: number, b: number) => a + b,
0,
)
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const pct = ((value / total) * 100).toFixed(1)
return `${label}: ${value} (${pct}%)`
},
@@ -325,9 +294,7 @@ const doughnutOptions = {
// 与评分明细共用同一份顺序,见 utils/constants
const CRITERIA_ORDER = FLOWCHART_CRITERIA_ORDER
const hasRadarData = computed(() =>
CRITERIA_ORDER.some((k) => k in data.criteriaAverages),
)
const hasRadarData = computed(() => CRITERIA_ORDER.some((k) => k in data.criteriaAverages))
const radarChartData = computed(() => {
const labels = CRITERIA_ORDER
@@ -384,12 +351,8 @@ const criteriaBarChartData = computed(() => {
{
label: "平均得分",
data: labels.map((k) => data.criteriaAverages[k]?.avg ?? 0),
backgroundColor: labels.map(
(_, i) => GRADE_COLORS[["S", "A", "B", "C"][i]].bg,
),
borderColor: labels.map(
(_, i) => GRADE_COLORS[["S", "A", "B", "C"][i]].border,
),
backgroundColor: labels.map((_, i) => GRADE_COLORS[["S", "A", "B", "C"][i]].bg),
borderColor: labels.map((_, i) => GRADE_COLORS[["S", "A", "B", "C"][i]].border),
borderWidth: 2,
},
],
@@ -473,24 +436,16 @@ function renderWordCloud() {
}
const subOptions = computed<Duration>(
() =>
durationFromValue(query.duration) ??
durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
() => durationFromValue(query.duration) ?? durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
)
async function handleStatistics() {
const current = Date.now()
const end = formatISO(current)
const duration =
query.duration === "all"
? { end }
: { start: formatISO(sub(current, subOptions.value)), end }
query.duration === "all" ? { end } : { start: formatISO(sub(current, subOptions.value)), end }
try {
const res = await getFlowchartStatistics(
duration,
query.problem,
query.username,
)
const res = await getFlowchartStatistics(duration, query.problem, query.username)
Object.assign(data, res)
} catch (error) {
message.error("获取流程图统计失败")
+10 -37
View File
@@ -27,9 +27,7 @@ const { isDark, toggleDark } = useDarkTransition()
* 也能看见有没有人举手。窄屏同样给:接单之后要在弹框里替学生写代码,那件事
* 确实只有桌面端好使,但「有没有人在等」是宽度多少都得知道的。
*/
const pendingHelpCount = computed(() =>
collabStore.isTeacher ? collabStore.pendingCount : 0,
)
const pendingHelpCount = computed(() => (collabStore.isTeacher ? collabStore.pendingCount : 0))
// 从 store 中获取屏幕模式状态
const { screenMode } = storeToRefs(screenModeStore)
@@ -119,17 +117,11 @@ const menus = computed<MenuOption[]>(() => [
icon: renderIcon("fluent-emoji:books"),
children: [
{
label: () =>
h(
RouterLink,
{ to: learnLink("python") },
{ default: () => "Python" },
),
label: () => h(RouterLink, { to: learnLink("python") }, { default: () => "Python" }),
key: "learn-python",
},
{
label: () =>
h(RouterLink, { to: learnLink("c") }, { default: () => "C语言" }),
label: () => h(RouterLink, { to: learnLink("c") }, { default: () => "C语言" }),
key: "learn-c",
},
],
@@ -140,14 +132,12 @@ const menus = computed<MenuOption[]>(() => [
icon: renderIcon("fluent-emoji:memo"),
},
{
label: () =>
h(RouterLink, { to: "/problemset" }, { default: () => "题单" }),
label: () => h(RouterLink, { to: "/problemset" }, { default: () => "题单" }),
key: "problemset",
icon: renderIcon("fluent-emoji:clipboard"),
},
{
label: () =>
h(RouterLink, { to: "/submission" }, { default: () => "提交" }),
label: () => h(RouterLink, { to: "/submission" }, { default: () => "提交" }),
key: "submission",
icon: renderIcon("fluent-emoji:inbox-tray"),
show: userStore.showSubmissions,
@@ -163,8 +153,7 @@ const menus = computed<MenuOption[]>(() => [
icon: renderIcon("fluent-emoji:trophy"),
},
{
label: () =>
h(RouterLink, { to: "/announcement" }, { default: () => "公告" }),
label: () => h(RouterLink, { to: "/announcement" }, { default: () => "公告" }),
key: "announcement",
icon: renderIcon("fluent-emoji:loudspeaker"),
},
@@ -183,9 +172,7 @@ const menus = computed<MenuOption[]>(() => [
const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
{
label: pendingHelpCount.value
? `课堂求助(${pendingHelpCount.value})`
: "课堂求助",
label: pendingHelpCount.value ? `课堂求助(${pendingHelpCount.value})` : "课堂求助",
key: "help",
show: collabStore.isTeacher,
icon: renderIcon("streamline-emojis:raising-hands-2"),
@@ -266,12 +253,7 @@ function goHome() {
</n-flex>
</n-button>
<div>
<n-menu
v-if="isDesktop"
mode="horizontal"
:options="menus"
:value="active"
/>
<n-menu v-if="isDesktop" mode="horizontal" :options="menus" :value="active" />
</div>
</n-flex>
<n-flex align="center">
@@ -282,10 +264,7 @@ function goHome() {
</n-button>
</n-dropdown>
<n-button
v-if="
isDesktop &&
(route.name === 'problem' || route.name === 'contest problem')
"
v-if="isDesktop && (route.name === 'problem' || route.name === 'contest problem')"
@click="() => screenModeStore.switchScreenMode()"
>
{{ screenMode }}
@@ -302,13 +281,7 @@ function goHome() {
</n-badge>
</n-dropdown>
<n-flex align="center" v-else>
<n-button
secondary
type="primary"
@click="authStore.openLoginModal()"
>
登录
</n-button>
<n-button secondary type="primary" @click="authStore.openLoginModal()"> 登录 </n-button>
<n-button
tertiary
v-if="configStore.config?.allowRegister"
@@ -83,26 +83,13 @@ const handleAccept = (studentId: number, problemId: string, status: string) => {
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
title="课堂求助"
:style="{ width: '420px' }"
>
<n-modal v-model:show="show" preset="card" title="课堂求助" :style="{ width: '420px' }">
<div style="max-height: 60vh; overflow: auto">
<n-alert
v-if="!isDesktop"
type="info"
:bordered="false"
style="margin-bottom: 8px"
>
<n-alert v-if="!isDesktop" type="info" :bordered="false" style="margin-bottom: 8px">
接单要在电脑上打开
</n-alert>
<n-empty
v-if="collabStore.groupedRequests.length === 0"
description="暂无求助"
/>
<n-empty v-if="collabStore.groupedRequests.length === 0" description="暂无求助" />
<div v-for="group in collabStore.groupedRequests" :key="group.problemId">
<!-- 同题多人是个教学信号:该停下来全班讲,而不是挨个救 -->
@@ -124,17 +111,14 @@ const handleAccept = (studentId: number, problemId: string, status: string) => {
padding: '6px 8px',
borderRadius: '4px',
opacity: item.status === 'active' ? 0.5 : 1,
cursor:
item.status === 'active' || !isDesktop ? 'default' : 'pointer',
cursor: item.status === 'active' || !isDesktop ? 'default' : 'pointer',
}"
@click="handleAccept(item.studentId, group.problemId, item.status)"
>
<n-flex vertical :size="2">
<n-text>
{{ item.studentName }}
<n-text depth="3" v-if="item.className"
>({{ item.className }})</n-text
>
<n-text depth="3" v-if="item.className">({{ item.className }})</n-text>
</n-text>
<n-text depth="3" style="font-size: 12px">
{{
+1 -6
View File
@@ -27,12 +27,7 @@ async function receive() {
onMounted(receive)
</script>
<template>
<div
class="hitokoto"
:title="hitokoto.sentence"
@click="receive"
v-if="hitokoto.sentence"
>
<div class="hitokoto" :title="hitokoto.sentence" @click="receive" v-if="hitokoto.sentence">
<span class="from">{{ "来自 " + hitokoto.from }}</span>
<span class="sentence">{{ hitokoto.sentence }}</span>
</div>
+9 -36
View File
@@ -86,9 +86,7 @@ const studentRules = computed<FormRules>(() => ({
password: passwordRule,
}))
const adminRules: FormRules = {
username: [
{ required: true, message: "用户名必填", trigger: ["blur", "change"] },
],
username: [{ required: true, message: "用户名必填", trigger: ["blur", "change"] }],
password: passwordRule,
}
@@ -196,10 +194,7 @@ function onUsernamePicked() {
function restoreLastClass() {
if (form.value.class !== null) return
const last = storage.get(STORAGE_KEY.LOGIN_CLASS)
if (
typeof last === "string" &&
classList.value.some((item) => item.value === last)
) {
if (typeof last === "string" && classList.value.some((item) => item.value === last)) {
form.value.class = last
}
}
@@ -234,12 +229,7 @@ onMounted(() => {
>
<n-tabs v-model:value="activeTab" @update:value="onTabChange">
<n-tab-pane name="student" tab="学生登录">
<n-form
ref="studentRef"
:model="form"
:rules="studentRules"
show-require-mark
>
<n-form ref="studentRef" :model="form" :rules="studentRules" show-require-mark>
<n-form-item label="班级" path="class">
<n-select
v-model:value="form.class"
@@ -250,10 +240,7 @@ onMounted(() => {
placeholder="选择班级"
/>
</n-form-item>
<n-form-item
:label="isClassLogin ? '姓名' : '用户名'"
path="username"
>
<n-form-item :label="isClassLogin ? '姓名' : '用户名'" path="username">
<n-select
v-if="isClassLogin"
v-model:value="form.username"
@@ -289,9 +276,7 @@ onMounted(() => {
@keyup.enter="submit"
/>
</n-form-item>
<n-alert v-if="msg" type="error" :show-icon="false">{{
msg
}}</n-alert>
<n-alert v-if="msg" type="error" :show-icon="false">{{ msg }}</n-alert>
<n-form-item>
<n-flex style="width: 100%">
<n-button
@@ -304,10 +289,7 @@ onMounted(() => {
>
登录
</n-button>
<n-button
v-if="configStore.config?.allowRegister"
@click="goSignup"
>
<n-button v-if="configStore.config?.allowRegister" @click="goSignup">
没有账号?立即注册
</n-button>
</n-flex>
@@ -320,12 +302,7 @@ onMounted(() => {
</n-tab-pane>
<n-tab-pane name="admin" tab="管理员登录">
<n-form
ref="adminRef"
:model="form"
:rules="adminRules"
show-require-mark
>
<n-form ref="adminRef" :model="form" :rules="adminRules" show-require-mark>
<n-form-item label="用户名" path="username">
<n-input
v-model:value="form.username"
@@ -347,13 +324,9 @@ onMounted(() => {
@keyup.enter="submit"
/>
</n-form-item>
<n-alert v-if="msg" type="error" :show-icon="false">{{
msg
}}</n-alert>
<n-alert v-if="msg" type="error" :show-icon="false">{{ msg }}</n-alert>
<n-form-item>
<n-button block type="primary" :loading="isLoading" @click="submit">
登录
</n-button>
<n-button block type="primary" :loading="isLoading" @click="submit"> 登录 </n-button>
</n-form-item>
<n-alert :show-icon="false" class="tip">
管理员和老师从这里登录,学生请走【学生登录】页签。
@@ -5,9 +5,7 @@ import { parseTime } from "utils/functions"
// md-editor-v3 按需加载:这个弹框挂在默认布局上,静态 import 的话首页每次都要多拉
// ~160KB JS + ~76KB CSS,而 AI 分析只在登录后、且期间提交够 3 次才有
const MdPreview = defineAsyncComponent(
() => import("./LoginSummaryPreview.vue"),
)
const MdPreview = defineAsyncComponent(() => import("./LoginSummaryPreview.vue"))
const loginSummaryStore = useLoginSummaryStore()
const { isDesktop } = useBreakpoints()
@@ -47,16 +45,10 @@ const hasAnalysis = computed(() => !!loginSummaryStore.analysis)
/>
</n-gi>
<n-gi>
<n-statistic
label="AC 次数"
:value="loginSummaryStore.summary?.acceptedCount ?? 0"
/>
<n-statistic label="AC 次数" :value="loginSummaryStore.summary?.acceptedCount ?? 0" />
</n-gi>
<n-gi>
<n-statistic
label="AC 题目数"
:value="loginSummaryStore.summary?.solvedCount ?? 0"
/>
<n-statistic label="AC 题目数" :value="loginSummaryStore.summary?.solvedCount ?? 0" />
</n-gi>
<n-gi>
<n-statistic
@@ -67,17 +59,10 @@ const hasAnalysis = computed(() => !!loginSummaryStore.analysis)
</n-grid>
<n-divider>AI 分析</n-divider>
<n-alert
v-if="loginSummaryStore.analysisError"
type="warning"
:show-icon="false"
>
<n-alert v-if="loginSummaryStore.analysisError" type="warning" :show-icon="false">
{{ loginSummaryStore.analysisError }}
</n-alert>
<MdPreview
v-if="hasAnalysis"
:model-value="loginSummaryStore.analysis"
/>
<MdPreview v-if="hasAnalysis" :model-value="loginSummaryStore.analysis" />
<n-empty v-else description="期间提交数少于 3 次,暂不生成 AI 分析" />
</n-flex>
</n-spin>
@@ -1,9 +1,5 @@
<template>
<MdEditor
:theme="isDark ? 'dark' : 'light'"
v-model="modelValue"
@onUploadImg="onUploadImg"
/>
<MdEditor :theme="isDark ? 'dark' : 'light'" v-model="modelValue" @onUploadImg="onUploadImg" />
</template>
<script setup lang="ts">
@@ -16,10 +12,7 @@ const modelValue = defineModel<string>("value")
const message = useMessage()
const onUploadImg = async (
files: File[],
callback: (urls: string[]) => void,
) => {
const onUploadImg = async (files: File[], callback: (urls: string[]) => void) => {
try {
const res = await Promise.all(
files.map(async (file) => {
@@ -52,12 +52,8 @@ onBeforeUnmount(() => {
<n-flex align="center">
<span>Mermaid 代码</span>
<n-flex align="center">
<n-button text @click="copyCode" size="small" type="primary">
复制
</n-button>
<n-button text @click="clearCode" type="error" size="small">
清空
</n-button>
<n-button text @click="copyCode" size="small" type="primary"> 复制 </n-button>
<n-button text @click="clearCode" type="error" size="small"> 清空 </n-button>
</n-flex>
</n-flex>
<n-input
@@ -70,16 +66,9 @@ onBeforeUnmount(() => {
<n-flex vertical>
<n-flex align="center" justify="space-between">
<span>图表预览</span>
<n-tag v-if="modelValue && renderSuccess" type="success" size="small">
✓ 渲染成功
</n-tag>
<n-tag v-if="modelValue && renderSuccess" type="success" size="small"> ✓ 渲染成功 </n-tag>
</n-flex>
<n-alert
v-if="renderError"
type="error"
title="Mermaid 语法错误"
style="margin-bottom: 8px"
>
<n-alert v-if="renderError" type="error" title="Mermaid 语法错误" style="margin-bottom: 8px">
<n-text style="font-size: 12px">{{ renderError }}</n-text>
</n-alert>
<div ref="mermaidContainer" class="mermaid-container"></div>
@@ -7,11 +7,7 @@ interface Props {
page: number
}
const {
total,
limit: initialLimit = 10,
page: initialPage = 1,
} = defineProps<Props>()
const { total, limit: initialLimit = 10, page: initialPage = 1 } = defineProps<Props>()
const emit = defineEmits(["update:limit", "update:page"])
+2 -5
View File
@@ -25,8 +25,7 @@ const rules: FormRules = {
{ required: true, message: "密码必填", trigger: "blur" },
{ min: 6, max: 20, message: "长度在 6 到 20 位之间", trigger: "input" },
{
validator: (_: FormItemRule, value: string) =>
value === form.value.password,
validator: (_: FormItemRule, value: string) => value === form.value.password,
message: "两次密码输入不一致",
trigger: "blur",
},
@@ -122,9 +121,7 @@ function submit() {
<n-alert v-if="msg" type="error" :show-icon="false"> {{ msg }}</n-alert>
<n-form-item>
<n-space>
<n-button type="primary" :loading="isLoading" @click="submit">
注册
</n-button>
<n-button type="primary" :loading="isLoading" @click="submit"> 注册 </n-button>
<n-button @click="goLogin">已经注册?现在登录</n-button>
</n-space>
</n-form-item>
@@ -15,17 +15,9 @@
style="width: 200px"
clearable
/>
<n-select
style="width: 120px"
v-model:value="query.duration"
:options="options"
/>
<n-button type="primary" :loading="loading" @click="handleStatistics">
统计
</n-button>
<n-button v-if="route.name !== 'submissions'" @click="goSubmissions">
前往提交列表
</n-button>
<n-select style="width: 120px" v-model:value="query.duration" :options="options" />
<n-button type="primary" :loading="loading" @click="handleStatistics"> 统计 </n-button>
<n-button v-if="route.name !== 'submissions'" @click="goSubmissions"> 前往提交列表 </n-button>
</n-flex>
<n-empty v-if="!hasResult" description="暂无数据" style="margin: 40px 0" />
@@ -35,46 +27,32 @@
<n-flex justify="space-around">
<div class="stat-item">
<n-text>总提交</n-text>
<n-gradient-text type="info" font-size="28">{{
count.total
}}</n-gradient-text>
<n-gradient-text type="info" font-size="28">{{ count.total }}</n-gradient-text>
</div>
<div class="stat-item">
<n-text>正确提交</n-text>
<n-gradient-text type="primary" font-size="28">{{
count.accepted
}}</n-gradient-text>
<n-gradient-text type="primary" font-size="28">{{ count.accepted }}</n-gradient-text>
</div>
<div class="stat-item" v-if="count.judging > 0">
<n-text>判题中</n-text>
<n-gradient-text type="info" font-size="28">{{
count.judging
}}</n-gradient-text>
<n-gradient-text type="info" font-size="28">{{ count.judging }}</n-gradient-text>
</div>
<div class="stat-item">
<n-text>正确率</n-text>
<n-gradient-text type="warning" font-size="28"
>{{ count.rate }}%</n-gradient-text
>
<n-gradient-text type="warning" font-size="28">{{ count.rate }}%</n-gradient-text>
</div>
<template v-if="personCount > 0">
<div class="stat-item">
<n-text>完成人数</n-text>
<n-gradient-text type="error" font-size="28">{{
doneList.length
}}</n-gradient-text>
<n-gradient-text type="error" font-size="28">{{ doneList.length }}</n-gradient-text>
</div>
<div class="stat-item">
<n-text>班级人数</n-text>
<n-gradient-text type="warning" font-size="28">{{
adjustedPersonCount
}}</n-gradient-text>
<n-gradient-text type="warning" font-size="28">{{ adjustedPersonCount }}</n-gradient-text>
</div>
<div class="stat-item">
<n-text>完成度</n-text>
<n-gradient-text type="success" font-size="28">{{
adjustedPersonRate
}}</n-gradient-text>
<n-gradient-text type="success" font-size="28">{{ adjustedPersonRate }}</n-gradient-text>
</div>
</template>
</n-flex>
@@ -90,10 +68,7 @@
</n-gi>
<n-gi v-if="personCount > 0">
<n-card title="班级完成度">
<Doughnut
:data="completionChartData"
:options="completionChartOptions"
/>
<Doughnut :data="completionChartData" :options="completionChartOptions" />
</n-card>
</n-gi>
</n-grid>
@@ -125,20 +100,11 @@
<template #checked>请假隐藏中</template>
<template #unchecked>请假隐藏</template>
</n-switch>
<n-button
v-if="hiddenCount > 0"
size="small"
type="info"
@click="showAll"
>
<n-button v-if="hiddenCount > 0" size="small" type="info" @click="showAll">
恢复 {{ hiddenCount }} 位
</n-button>
</n-flex>
<n-gradient-text
v-if="unfinishedGroups.length === 0"
font-size="24"
type="success"
>
<n-gradient-text v-if="unfinishedGroups.length === 0" font-size="24" type="success">
全都完成了
</n-gradient-text>
<template v-for="group in unfinishedGroups" :key="group.title">
@@ -163,10 +129,7 @@
>
{{ item.label }}
</n-tag>
<span
v-else
:class="{ name: true, 'name-clickable': !!item.failure }"
>
<span v-else :class="{ name: true, 'name-clickable': !!item.failure }">
{{ item.label }}
</span>
</template>
@@ -178,11 +141,7 @@
<pre v-if="item.failure?.error" class="failure-error">{{
item.failure?.error
}}</pre>
<n-button
size="small"
tertiary
@click="openFailure(item.failure?.id)"
>
<n-button size="small" tertiary @click="openFailure(item.failure?.id)">
看代码
</n-button>
</n-flex>
@@ -280,10 +239,7 @@ function groupByProblem(list: SubmissionStatisticsItems["items"]) {
item.result === SubmissionStatus.ast_check_failed,
),
}))
.sort(
(a, b) =>
Number(a.solved) - Number(b.solved) || b.items.length - a.items.length,
)
.sort((a, b) => Number(a.solved) - Number(b.solved) || b.items.length - a.items.length)
}
function openSubmission(id: string) {
@@ -310,17 +266,12 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
style: "width: 200px; flex: none",
},
() => [
h(
NTag,
{ size: "small", bordered: false },
() => group.problemDisplayId,
),
h(NTag, { size: "small", bordered: false }, () => group.problemDisplayId),
h(
NText,
{
depth: 2,
style:
"overflow: hidden; text-overflow: ellipsis; white-space: nowrap",
style: "overflow: hidden; text-overflow: ellipsis; white-space: nowrap",
},
() => group.problemTitle,
),
@@ -332,46 +283,39 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
depth: 3,
style: "width: 104px; flex: none",
},
() =>
`${group.items.length} 次 · ${group.solved ? "已通过" : "未通过"}`,
() => `${group.items.length} 次 · ${group.solved ? "已通过" : "未通过"}`,
),
h(
NFlex,
{ size: 4, wrap: true, style: "flex: 1; min-width: 0" },
() =>
group.items.map((item) =>
h(
NTooltip,
{ delay: 200 },
{
trigger: () =>
h("button", {
// 内联样式而不是 class:这些方块是 h() 出来、挂在 NDataTable 的
// 展开槽里渲染的,<style scoped> 能不能盖到它并不确定
style: {
width: "14px",
height: "14px",
padding: "0",
border: "none",
borderRadius: "3px",
cursor: "pointer",
background:
ATTEMPT_COLORS[
JUDGE_STATUS[item.result]?.type ?? "default"
],
},
onClick: (event: MouseEvent) => {
event.stopPropagation()
openSubmission(item.id)
},
}),
default: () =>
`${JUDGE_STATUS[item.result]?.name ?? item.result} · ` +
`${parseTime(item.createTime, "MM-DD HH:mm:ss")} · ` +
`${item.id.toString().slice(0, 12)}`,
},
),
h(NFlex, { size: 4, wrap: true, style: "flex: 1; min-width: 0" }, () =>
group.items.map((item) =>
h(
NTooltip,
{ delay: 200 },
{
trigger: () =>
h("button", {
// 内联样式而不是 class:这些方块是 h() 出来、挂在 NDataTable 的
// 展开槽里渲染的,<style scoped> 能不能盖到它并不确定
style: {
width: "14px",
height: "14px",
padding: "0",
border: "none",
borderRadius: "3px",
cursor: "pointer",
background: ATTEMPT_COLORS[JUDGE_STATUS[item.result]?.type ?? "default"],
},
onClick: (event: MouseEvent) => {
event.stopPropagation()
openSubmission(item.id)
},
}),
default: () =>
`${JUDGE_STATUS[item.result]?.name ?? item.result} · ` +
`${parseTime(item.createTime, "MM-DD HH:mm:ss")} · ` +
`${item.id.toString().slice(0, 12)}`,
},
),
),
),
]),
),
@@ -379,8 +323,7 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
? h(
NText,
{ depth: 3 },
() =>
`只显示最近 ${loaded.items.length} 条,上面「提交数」才是总数`,
() => `只显示最近 ${loaded.items.length} 条,上面「提交数」才是总数`,
)
: null,
])
@@ -501,11 +444,7 @@ async function loadItems(username: string) {
end: formatISO(current),
}
try {
items[username] = await getSubmissionStatisticsItems(
duration,
username,
query.problem,
)
items[username] = await getSubmissionStatisticsItems(duration, username, query.problem)
} catch {
// 拉不到就当空的:展开行显示不出东西,但不该把整个面板带崩
items[username] = { items: [], truncated: false }
@@ -519,12 +458,9 @@ async function loadItems(username: string) {
* 已经把整份花名册当作「未完成」返回了,而那正是老师最想看名单的时刻。
* 原来整个面板 v-if 在 count.total > 0 上,那会儿只显示「暂无数据」。
*/
const hasResult = computed(
() => count.total > 0 || listUnaccepted.value.length > 0,
)
const hasResult = computed(() => count.total > 0 || listUnaccepted.value.length > 0)
const { hideMode, hideStudent, showAll, notHidden } =
useHiddenStudents("oj_hidden_students")
const { hideMode, hideStudent, showAll, notHidden } = useHiddenStudents("oj_hidden_students")
const visibleUnaccepted = computed(() => listUnaccepted.value.filter(notHidden))
const visibleAttempted = computed(() => listAttempted.value.filter(notHidden))
@@ -560,27 +496,26 @@ type UnfinishedItem = {
failure: AttemptedStudent["lastFailure"]
}
const unfinishedGroups = computed<{ title: string; items: UnfinishedItem[] }[]>(
() =>
[
{
title: "还没交",
items: visibleUnaccepted.value.map((item) => ({
username: item.username,
label: item.realName,
failure: null,
})),
},
{
// 查多道题时这一栏混着「一道没对」和「差一道」两种人,标题得说清是「没全对」
title: queriedProblemCount.value > 1 ? "交了没全对" : "交了没对",
items: visibleAttempted.value.map((item) => ({
username: item.username,
label: attemptedLabel(item),
failure: item.lastFailure,
})),
},
].filter((group) => group.items.length > 0),
const unfinishedGroups = computed<{ title: string; items: UnfinishedItem[] }[]>(() =>
[
{
title: "还没交",
items: visibleUnaccepted.value.map((item) => ({
username: item.username,
label: item.realName,
failure: null,
})),
},
{
// 查多道题时这一栏混着「一道没对」和「差一道」两种人,标题得说清是「没全对」
title: queriedProblemCount.value > 1 ? "交了没全对" : "交了没对",
items: visibleAttempted.value.map((item) => ({
username: item.username,
label: attemptedLabel(item),
failure: item.lastFailure,
})),
},
].filter((group) => group.items.length > 0),
)
type FailureResult = NonNullable<AttemptedStudent["lastFailure"]>["result"]
@@ -600,16 +535,11 @@ function openFailure(id?: string) {
if (id) openSubmission(id)
}
const adjustedPersonCount = computed(
() => personCount.value - hiddenCount.value,
)
const adjustedPersonCount = computed(() => personCount.value - hiddenCount.value)
const adjustedPersonRate = computed(() => {
if (adjustedPersonCount.value <= 0) return "0%"
const rate = Math.min(
100,
(doneList.value.length / adjustedPersonCount.value) * 100,
)
const rate = Math.min(100, (doneList.value.length / adjustedPersonCount.value) * 100)
return `${Math.round(rate * 100) / 100}%`
})
@@ -659,10 +589,7 @@ const pieChartOptions = {
label: function (context: any) {
const label = context.label || ""
const value = context.parsed || 0
const total = context.dataset.data.reduce(
(a: number, b: number) => a + b,
0,
)
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = ((value / total) * 100).toFixed(1)
return `${label}: ${value} (${percentage}%)`
},
@@ -674,10 +601,7 @@ const pieChartOptions = {
// 环形图数据 - 班级完成度
const completionChartData = computed(() => {
const completedCount = list.value.length
const uncompletedCount = Math.max(
0,
adjustedPersonCount.value - completedCount,
)
const uncompletedCount = Math.max(0, adjustedPersonCount.value - completedCount)
return {
labels: ["已完成", "未完成"],
datasets: [
@@ -704,10 +628,7 @@ const completionChartOptions = {
label: function (context: any) {
const label = context.label || ""
const value = context.parsed || 0
const total = context.dataset.data.reduce(
(a: number, b: number) => a + b,
0,
)
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = ((value / total) * 100).toFixed(1)
return `${label}: ${value} (${percentage}%)`
},
@@ -719,9 +640,7 @@ const completionChartOptions = {
const subOptions = computed<Duration>(
// 认不出来(含 all)就退回列表第一档,和原来 `?? options[0]` 一致;
// all 实际不会走到这里,handleStatistics 先分支掉了
() =>
durationFromValue(query.duration) ??
durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
() => durationFromValue(query.duration) ?? durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
)
function goSubmissions() {
@@ -750,18 +669,12 @@ async function fetchStatistics() {
const current = Date.now()
const end = formatISO(current)
const duration =
query.duration === "all"
? { end }
: { start: formatISO(sub(current, subOptions.value)), end }
query.duration === "all" ? { end } : { start: formatISO(sub(current, subOptions.value)), end }
const problems = query.problem
.split(/[,,;;\s]+/)
.map((item) => item.trim())
.filter(Boolean)
const res = await getSubmissionStatistics(
duration,
query.problem,
query.username,
)
const res = await getSubmissionStatistics(duration, query.problem, query.username)
queriedProblemCount.value = new Set(problems.map((p) => p.toLowerCase())).size
count.total = res.submissionCount
count.accepted = res.acceptedCount
@@ -1,11 +1,7 @@
<script lang="ts" setup>
import { bracketMatching } from "@codemirror/language"
import { Codemirror } from "vue-codemirror"
import {
autocompletion,
closeBrackets,
completeAnyWord,
} from "@codemirror/autocomplete"
import { autocompletion, closeBrackets, completeAnyWord } from "@codemirror/autocomplete"
import type { EditorView } from "@codemirror/view"
import type { Extension } from "@codemirror/state"
import type { LANGUAGE } from "utils/types"
@@ -78,9 +74,7 @@ const editorView = shallowRef<EditorView | null>(null)
/** 房间开着,而且开的就是这道题 */
const roomIsHere = computed(
() =>
collabStore.room !== null &&
(!problemId || collabStore.room.problemId === problemId),
() => collabStore.room !== null && (!problemId || collabStore.room.problemId === problemId),
)
/**
@@ -1,9 +1,5 @@
<script setup lang="ts">
import type {
IDomEditor,
IEditorConfig,
IToolbarConfig,
} from "@wangeditor-next/editor"
import type { IDomEditor, IEditorConfig, IToolbarConfig } from "@wangeditor-next/editor"
import { Editor, Toolbar } from "@wangeditor-next/editor-for-vue"
import "@wangeditor-next/editor/dist/css/style.css"
import { uploadImage } from "../../admin/api"
@@ -1,7 +1,4 @@
import {
breakpointsTailwind,
useBreakpoints as useVueUseBreakpoints,
} from "@vueuse/core"
import { breakpointsTailwind, useBreakpoints as useVueUseBreakpoints } from "@vueuse/core"
/**
* 响应式断点检测 composable
@@ -11,9 +11,7 @@ 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 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)",
)
+11 -20
View File
@@ -47,21 +47,16 @@ 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
@@ -112,11 +107,7 @@ export function useCollabDoc() {
detachDocUpdate = () => doc?.off("update", onDocUpdate)
const onAwarenessUpdate = (
{
added,
updated,
removed,
}: { added: number[]; updated: number[]; removed: number[] },
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
origin: any,
) => {
if (origin === "remote") return
@@ -1,9 +1,6 @@
import { useConfigStore } from "shared/store/config"
import { useUserStore } from "shared/store/user"
import {
useConfigWebSocket,
type ConfigUpdate,
} from "shared/composables/websocket"
import { useConfigWebSocket, type ConfigUpdate } from "shared/composables/websocket"
/**
* 收 /ws/config 的站点配置广播,写进 configStore —— 超管改完配置,所有开着页面
@@ -19,8 +16,7 @@ export function useConfigUpdate() {
const handleConfigUpdate = (data: ConfigUpdate) => {
// 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段
if (!(data.key in configStore.config)) return
;(configStore.config as unknown as Record<string, unknown>)[data.key] =
data.value
;(configStore.config as unknown as Record<string, unknown>)[data.key] = data.value
// getConfig() 里也是这么设的,站点改名后标签页跟着变,别只更新页面里那份
if (data.key === "websiteName") document.title = data.value
}
@@ -40,10 +40,7 @@ export function useDarkTransition() {
.ready.then(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${radius}px at ${x}px ${y}px)`,
],
clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`],
},
{
duration: 400,
@@ -64,9 +64,7 @@ export function useHiddenStudents(storageKey: string) {
// 把已经到期的清掉再落一次盘,否则这张表只增不减
const now = Date.now()
const cleaned = Object.fromEntries(
Object.entries(hiddenStudents.value).filter(
([, expiresAt]) => expiresAt > now,
),
Object.entries(hiddenStudents.value).filter(([, expiresAt]) => expiresAt > now),
)
hiddenStudents.value = cleaned
save(cleaned)
@@ -5,10 +5,10 @@ export type TutorialType = "python" | "c"
// 模块级单例:学习页写入、导航栏读取,必须共用同一个响应式引用,
// 否则导航栏的链接不会随学习进度更新
const learnStep = useStorage<Record<TutorialType, number>>(
STORAGE_KEY.LEARN_CURRENT_STEP,
{ python: 1, c: 1 },
)
const learnStep = useStorage<Record<TutorialType, number>>(STORAGE_KEY.LEARN_CURRENT_STEP, {
python: 1,
c: 1,
})
export function useLearnProgress() {
return { learnStep }
@@ -27,11 +27,7 @@ export function usePagination<T extends Record<string, any>>(
initialQuery: Omit<T, "page" | "limit"> = {} as Omit<T, "page" | "limit">,
options: UsePaginationOptions = {},
) {
const {
defaultLimit = 10,
defaultPage = 1,
resetPageOnChange = true,
} = options
const { defaultLimit = 10, defaultPage = 1, resetPageOnChange = true } = options
const route = useRoute()
const router = useRouter()
+1 -3
View File
@@ -9,7 +9,5 @@ import { RARITY_TEXT_COLOR } from "utils/constants"
*/
export function useRarityColor() {
const isDark = useDark()
return computed(() =>
isDark.value ? RARITY_TEXT_COLOR.dark : RARITY_TEXT_COLOR.light,
)
return computed(() => (isDark.value ? RARITY_TEXT_COLOR.dark : RARITY_TEXT_COLOR.light))
}
@@ -286,10 +286,7 @@ export function useMermaid() {
const renderSuccess = ref(false)
let renderGeneration = 0
const renderFlowchart = async (
container: HTMLElement | null,
mermaidCode: string,
) => {
const renderFlowchart = async (container: HTMLElement | null, mermaidCode: string) => {
renderError.value = null
renderSuccess.value = false
@@ -308,10 +305,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 : "流程图渲染失败,请检查代码格式"
}
}
+9 -28
View File
@@ -35,11 +35,7 @@ function handleForceLogout(reason: string) {
/**
* WebSocket 连接状态
*/
export type ConnectionStatus =
| "disconnected"
| "connecting"
| "connected"
| "error"
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"
/**
* WebSocket 消息类型
@@ -72,9 +68,7 @@ function channelUrl(path: string) {
/**
* WebSocket 消息处理器
*/
export type MessageHandler<T extends WebSocketMessage = WebSocketMessage> = (
data: T,
) => void
export type MessageHandler<T extends WebSocketMessage = WebSocketMessage> = (data: T) => void
/**
* WebSocket 基础连接管理类
@@ -113,8 +107,7 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
if (
this.ws &&
(this.ws.readyState === WebSocket.OPEN ||
this.ws.readyState === WebSocket.CONNECTING)
(this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)
) {
return
}
@@ -185,9 +178,7 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
// 收尾由 disconnect() 自己做 —— 也就不会再像以前那样「断完立刻重连」
if (ws !== this.ws) return
this.ws = null
console.log(
`[WebSocket] 连接关闭: code=${event.code}, reason=${event.reason}`,
)
console.log(`[WebSocket] 连接关闭: code=${event.code}, reason=${event.reason}`)
this.status.value = "disconnected"
this.stopHeartbeat()
this.onDisconnected(event)
@@ -214,9 +205,7 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
RECONNECT_MAX_DELAY,
)
const delay = Math.round(base * (0.5 + Math.random() * 0.5))
console.log(
`[WebSocket] 将在 ${delay}ms 后重连 (第 ${this.reconnectAttempts} 次)`,
)
console.log(`[WebSocket] 将在 ${delay}ms 后重连 (第 ${this.reconnectAttempts} 次)`)
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null
this.connect()
@@ -238,8 +227,7 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
if (this.closedByUser) return
if (
this.ws &&
(this.ws.readyState === WebSocket.OPEN ||
this.ws.readyState === WebSocket.CONNECTING)
(this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)
) {
return
}
@@ -481,10 +469,7 @@ export interface SubmissionUpdate extends WebSocketMessage {
*
* 每次调用都新建一条连接(和原来一致,不是单例),并在组件卸载时摘掉 handler、断开。
*/
function useChannel<T extends WebSocketMessage>(
path: string,
handler?: MessageHandler<T>,
) {
function useChannel<T extends WebSocketMessage>(path: string, handler?: MessageHandler<T>) {
const ws = new BaseWebSocket<T>(path)
// 同步注册,不放 onMounted:调用方(如 useConfigUpdate)在 setup 阶段就 connect(),
@@ -521,9 +506,7 @@ export interface SubmissionUpdate extends WebSocketMessage {
}
/** 判题进度。subscribe(submissionId) 认领,断线重连会自动补订阅 */
export function useSubmissionWebSocket(
handler?: MessageHandler<SubmissionUpdate>,
) {
export function useSubmissionWebSocket(handler?: MessageHandler<SubmissionUpdate>) {
return useChannel<SubmissionUpdate>("/ws/submissions", handler)
}
@@ -548,9 +531,7 @@ export interface FlowchartEvaluationUpdate extends WebSocketMessage {
* 流程图评分进度。和判题走的是**同一条** `/ws/submissions` ——
* 服务端按 submissionId 分辨是代码提交还是流程图,这里只是换一套消息类型。
*/
export function useFlowchartWebSocket(
handler?: MessageHandler<FlowchartEvaluationUpdate>,
) {
export function useFlowchartWebSocket(handler?: MessageHandler<FlowchartEvaluationUpdate>) {
return useChannel<FlowchartEvaluationUpdate>("/ws/submissions", handler)
}
@@ -38,9 +38,7 @@ function sqlSchemaCompletions(): Completion[] {
},
...table.columns.map((col) => ({
label: col.name,
detail: col.type
? `${table.name} 的字段 · ${col.type}`
: `${table.name} 的字段`,
detail: col.type ? `${table.name} 的字段 · ${col.type}` : `${table.name} 的字段`,
type: "property",
boost: 105,
})),
@@ -48,51 +46,38 @@ function sqlSchemaCompletions(): Completion[] {
}
export function enhanceCompletion(language: LANGUAGE): CompletionSource {
return async function (
context: CompletionContext,
): Promise<CompletionResult | null> {
return async function (context: CompletionContext): Promise<CompletionResult | null> {
const word = context.matchBefore(/\w+/)
if (!word && !context.explicit) return null
const trulyLanguage =
language === "SQL"
? "sql"
: language.startsWith("Python")
? "python"
: "c"
const completions: Completion[] = (
chineseAnnotations[trulyLanguage] || []
).map((completion) => {
const insertText =
typeof completion.apply === "string"
? completion.apply
: completion.label
const cursorOffset = insertText.includes("(")
? insertText.indexOf("(") + 1
: insertText.length
language === "SQL" ? "sql" : language.startsWith("Python") ? "python" : "c"
const completions: Completion[] = (chineseAnnotations[trulyLanguage] || []).map(
(completion) => {
const insertText =
typeof completion.apply === "string" ? completion.apply : completion.label
const cursorOffset = insertText.includes("(")
? insertText.indexOf("(") + 1
: insertText.length
if (
(completion.type === "function" || completion.type === "method") &&
insertText.includes(")")
) {
return {
...completion,
apply: (
view: EditorView,
_c: Completion,
from: number,
to: number,
) => {
view.dispatch({
changes: { from, to, insert: insertText },
selection: { anchor: from + cursorOffset },
})
},
if (
(completion.type === "function" || completion.type === "method") &&
insertText.includes(")")
) {
return {
...completion,
apply: (view: EditorView, _c: Completion, from: number, to: number) => {
view.dispatch({
changes: { from, to, insert: insertText },
selection: { anchor: from + cursorOffset },
})
},
}
}
}
return completion
})
return completion
},
)
if (trulyLanguage === "sql") {
completions.push(...sqlSchemaCompletions())
+1 -2
View File
@@ -11,7 +11,6 @@ import type { LANGUAGE } from "utils/types"
* Java / Golang / JavaScript 没有单独的包,落到 cpp(),是既有行为,不是遗漏。
*/
export function languageExtension(language: LANGUAGE): Extension {
if (language === "SQL")
return sql({ dialect: SQLite, upperCaseKeywords: true })
if (language === "SQL") return sql({ dialect: SQLite, upperCaseKeywords: true })
return language === "Python" ? python() : cpp()
}
+16 -68
View File
@@ -22,8 +22,7 @@ const options = computed<MenuOption[]>(() => {
// Student Admin: only problems
if (userStore.isStudentAdmin) {
baseOptions.push({
label: () =>
h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }),
label: () => h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }),
key: "admin problem list",
})
}
@@ -32,40 +31,23 @@ const options = computed<MenuOption[]>(() => {
if (userStore.isTeacherAdmin) {
baseOptions.push(
{
label: () =>
h(
RouterLink,
{ to: "/admin/problem/list" },
{ default: () => "题目" },
),
label: () => h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }),
key: "admin problem list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/contest/list" },
{ default: () => "比赛" },
),
label: () => h(RouterLink, { to: "/admin/contest/list" }, { default: () => "比赛" }),
key: "admin contest list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/problemset/list" },
{ default: () => "题单" },
),
label: () => h(RouterLink, { to: "/admin/problemset/list" }, { default: () => "题单" }),
key: "admin problemset list",
},
{
label: () =>
h(RouterLink, { to: "/admin/ai/reports" }, { default: () => "报告" }),
label: () => h(RouterLink, { to: "/admin/ai/reports" }, { default: () => "报告" }),
key: "admin ai reports",
},
{
label: () =>
h(RouterLink, { to: "/admin/learn" }, { default: () => "自学" }),
label: () => h(RouterLink, { to: "/admin/learn" }, { default: () => "自学" }),
key: "admin learn analytics",
},
)
@@ -79,77 +61,43 @@ const options = computed<MenuOption[]>(() => {
key: "admin home",
},
{
label: () =>
h(RouterLink, { to: "/admin/config" }, { default: () => "设置" }),
label: () => h(RouterLink, { to: "/admin/config" }, { default: () => "设置" }),
key: "admin config",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/problem/list" },
{ default: () => "题目" },
),
label: () => h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }),
key: "admin problem list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/contest/list" },
{ default: () => "比赛" },
),
label: () => h(RouterLink, { to: "/admin/contest/list" }, { default: () => "比赛" }),
key: "admin contest list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/problemset/list" },
{ default: () => "题单" },
),
label: () => h(RouterLink, { to: "/admin/problemset/list" }, { default: () => "题单" }),
key: "admin problemset list",
},
{
label: () =>
h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }),
label: () => h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }),
key: "admin user list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/announcement/list" },
{ default: () => "公告" },
),
label: () => h(RouterLink, { to: "/admin/announcement/list" }, { default: () => "公告" }),
key: "admin announcement list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/tutorial/list" },
{ default: () => "教程" },
),
label: () => h(RouterLink, { to: "/admin/tutorial/list" }, { default: () => "教程" }),
key: "admin tutorial list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/achievement/list" },
{ default: () => "成就" },
),
label: () => h(RouterLink, { to: "/admin/achievement/list" }, { default: () => "成就" }),
key: "admin achievement list",
},
{
label: () =>
h(RouterLink, { to: "/admin/ai/reports" }, { default: () => "报告" }),
label: () => h(RouterLink, { to: "/admin/ai/reports" }, { default: () => "报告" }),
key: "admin ai reports",
},
{
label: () =>
h(RouterLink, { to: "/admin/learn" }, { default: () => "自学" }),
label: () => h(RouterLink, { to: "/admin/learn" }, { default: () => "自学" }),
key: "admin learn analytics",
},
)
+1 -4
View File
@@ -1,7 +1,4 @@
import {
getPendingAchievements,
markAchievementsRead,
} from "oj/achievement/api"
import { getPendingAchievements, markAchievementsRead } from "oj/achievement/api"
import type { QueuedAchievement } from "utils/types"
/**
+4 -12
View File
@@ -56,9 +56,7 @@ export const useCollabStore = defineStore("collab", () => {
noticeSeq.value += 1
}
const pendingCount = computed(
() => requests.value.filter((it) => it.status === "pending").length,
)
const pendingCount = computed(() => requests.value.filter((it) => it.status === "pending").length)
/** 按题目聚合,同题多人时老师能一眼看出该停下来全班讲 */
const groupedRequests = computed(() => {
@@ -78,9 +76,7 @@ export const useCollabStore = defineStore("collab", () => {
}
// 人多的题排前面;人数相同按最久等待排
return Array.from(groups.values()).sort(
(a, b) =>
b.items.length - a.items.length ||
a.items[0].createdAt - b.items[0].createdAt,
(a, b) => b.items.length - a.items.length || a.items[0].createdAt - b.items[0].createdAt,
)
})
@@ -101,14 +97,10 @@ export const useCollabStore = defineStore("collab", () => {
// 对面离开了这道题:学生那侧要说明求助的去向 —— 服务端紧接着会补一条
// help_status:pending 把他放回队列
case "peer_left":
return teacher
? "学生离开了这道题,协作已结束"
: "老师暂时离开,你的求助已重新排队"
return teacher ? "学生离开了这道题,协作已结束" : "老师暂时离开,你的求助已重新排队"
// 自己离开了这道题(跳走页面、把语言切成流程图)
case "self_left":
return teacher
? "你已离开这道题,求助退回队列了"
: "你已离开这道题,协作结束"
return teacher ? "你已离开这道题,求助退回队列了" : "你已离开这道题,协作结束"
// done:有人点了「结束协作」
default:
return teacher ? "已结束这次协作" : "老师已结束这次帮忙"
+4 -9
View File
@@ -2,21 +2,16 @@ import { defineStore } from "pinia"
import { ScreenMode } from "utils/constants"
export const useScreenModeStore = defineStore("screenMode", () => {
const { state: screenMode, next: switchScreenMode } = useCycleList(
Object.values(ScreenMode),
{
initialValue: ScreenMode.both,
},
)
const { state: screenMode, next: switchScreenMode } = useCycleList(Object.values(ScreenMode), {
initialValue: ScreenMode.both,
})
// 计算属性
const isBothMode = computed(() => screenMode.value === ScreenMode.both)
const isCodeOnlyMode = computed(() => screenMode.value === ScreenMode.code)
const shouldShowProblem = computed(
() =>
screenMode.value === ScreenMode.both ||
screenMode.value === ScreenMode.problem,
() => screenMode.value === ScreenMode.both || screenMode.value === ScreenMode.problem,
)
function resetScreenMode() {
+2 -6
View File
@@ -17,9 +17,7 @@ export const useUserStore = defineStore("user", () => {
// 不受伪装影响的真实身份,只用于判断能否切换演示模式。
// 若这里用被伪装后的 isSuperAdmin,一进入演示模式入口就消失了,退不出来。
const realIsSuperAdmin = computed(
() => user.value?.adminType === USER_TYPE.SUPER_ADMIN,
)
const realIsSuperAdmin = computed(() => user.value?.adminType === USER_TYPE.SUPER_ADMIN)
const isAdminRole = computed(
() =>
@@ -42,9 +40,7 @@ export const useUserStore = defineStore("user", () => {
)
const isSuperAdmin = computed(() => !demoMode.value && realIsSuperAdmin.value)
const hasProblemPermission = computed(
() =>
!demoMode.value &&
user.value?.problemPermission !== PROBLEM_PERMISSION.NONE,
() => !demoMode.value && user.value?.problemPermission !== PROBLEM_PERMISSION.NONE,
)
const canToggleDemoMode = computed(() => realIsSuperAdmin.value)
+5 -14
View File
@@ -1,8 +1,4 @@
import {
HighlightStyle,
TagStyle,
syntaxHighlighting,
} from "@codemirror/language"
import { HighlightStyle, TagStyle, syntaxHighlighting } from "@codemirror/language"
import { Extension } from "@codemirror/state"
import { EditorView } from "@codemirror/view"
@@ -64,11 +60,7 @@ interface Settings {
gutterBorderRight: string
}
export const createTheme = ({
variant,
settings,
styles,
}: Options): Extension => {
export const createTheme = ({ variant, settings, styles }: Options): Extension => {
const theme = EditorView.theme(
{
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -82,10 +74,9 @@ export const createTheme = ({
".cm-cursor, .cm-dropCursor": {
borderLeftColor: settings.caret,
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
{
backgroundColor: settings.selection,
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: settings.selection,
},
".cm-activeLine": {
backgroundColor: settings.lineHighlight,
},
+5 -15
View File
@@ -35,8 +35,9 @@ const oneDarkTheme = EditorView.theme(
},
".cm-cursor, .cm-dropCursor": { borderLeftColor: cursor },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
{ backgroundColor: selection },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: selection,
},
".cm-panels": { backgroundColor: darkBackground, color: ivory },
".cm-panels.cm-panels-top": { borderBottom: "2px solid black" },
@@ -119,15 +120,7 @@ const oneDarkHighlightStyle = HighlightStyle.define([
color: chalky,
},
{
tag: [
t.operator,
t.operatorKeyword,
t.url,
t.escape,
t.regexp,
t.link,
t.special(t.string),
],
tag: [t.operator, t.operatorKeyword, t.url, t.escape, t.regexp, t.link, t.special(t.string)],
color: cyan,
},
{ tag: [t.meta, t.comment], color: stone },
@@ -143,7 +136,4 @@ const oneDarkHighlightStyle = HighlightStyle.define([
/// Extension to enable the One Dark theme (both the editor theme and
/// the highlight style).
export const oneDark: Extension = [
oneDarkTheme,
syntaxHighlighting(oneDarkHighlightStyle),
]
export const oneDark: Extension = [oneDarkTheme, syntaxHighlighting(oneDarkHighlightStyle)]