1 parent
23844ae301
commit
d25b5dbe6d
268 files changed
+3684
-8861
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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("获取流程图统计失败")
|
||||
|
||||
@@ -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">
|
||||
{{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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)",
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 : "流程图渲染失败,请检查代码格式"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,7 +1,4 @@
|
||||
import {
|
||||
getPendingAchievements,
|
||||
markAchievementsRead,
|
||||
} from "oj/achievement/api"
|
||||
import { getPendingAchievements, markAchievementsRead } from "oj/achievement/api"
|
||||
import type { QueuedAchievement } from "utils/types"
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ? "已结束这次协作" : "老师已结束这次帮忙"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)]
|
||||
Reference in new issue
Block a user