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

@@ -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"