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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user