feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码

This commit is contained in:
2026-08-06 21:18:16 -06:00
parent 3c975e85ee
commit ae1fb329b5
258 changed files with 40490 additions and 91 deletions

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
// 成就图标统一走 iconifynoto 彩色 emoji 图标集),渲染出来是 SVG
// 老浏览器缺 emoji 字体也能正常显示。
//
// 三种形态按顺序判断,顺序不能换:题单奖章的 icon 是图片 URL
// 而 https:// 里也带冒号,先判 iconify 会把 URL 当成图标名。
// 存量成就的 icon 还可能是 emoji 字符,最后原样当文本兜底。
const props = withDefaults(defineProps<{ icon: string; size?: number }>(), {
size: 32,
})
const kind = computed(() => {
const v = props.icon ?? ""
if (/^(https?:\/\/|\/|data:)/.test(v)) return "image"
if (v.includes(":")) return "iconify"
return "text"
})
</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"
/>
<span v-else class="fallback" :style="{ fontSize: `${size}px` }">
{{ icon }}
</span>
</template>
<style scoped>
.image {
object-fit: contain;
border-radius: 4px;
}
.fallback {
line-height: 1;
}
</style>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useAchievementStore } from "shared/store/achievement"
import { RARITY_COLOR } from "utils/constants"
const store = useAchievementStore()
const { current, queue } = storeToRefs(store)
const visible = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
let gapTimer: ReturnType<typeof setTimeout> | null = null
// 多个同时解锁时排队依次弹出,不重叠堆积
function playNext() {
const item = store.next()
if (!item) return
visible.value = true
timer = setTimeout(async () => {
visible.value = false
await store.markRead(item)
gapTimer = setTimeout(playNext, 400)
}, 3000)
}
watch(
() => queue.value.length,
(len) => {
if (len > 0 && !visible.value) playNext()
},
)
onUnmounted(() => {
if (timer) clearTimeout(timer)
if (gapTimer) clearTimeout(gapTimer)
})
</script>
<template>
<Transition name="slide">
<div
v-if="visible && current"
class="toast"
:style="{ borderColor: RARITY_COLOR[current.rarity] }"
>
<div class="icon">
<AchievementIcon :icon="current.icon" :size="34" />
</div>
<div class="body">
<div class="label">
{{ current.kind === "badge" ? "获得奖章" : "成就解锁" }}
</div>
<div class="name">{{ current.name }}</div>
<div class="desc">{{ current.description }}</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.toast {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 3000;
display: flex;
gap: 12px;
align-items: center;
padding: 14px 18px;
border-radius: 10px;
border: 2px solid;
background: var(--n-color, rgba(24, 24, 28, 0.95));
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
min-width: 260px;
}
.icon {
font-size: 34px;
}
.label {
font-size: 11px;
letter-spacing: 2px;
opacity: 0.6;
}
.name {
font-weight: 700;
margin-top: 2px;
}
.desc {
font-size: 12px;
opacity: 0.7;
margin-top: 2px;
}
.slide-enter-active,
.slide-leave-active {
transition: all 0.35s ease;
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
transform: translateX(40px);
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<n-select
style="width: 140px"
v-model:value="author"
remote
@update:show="getAuthorOptions"
:options="authorOptions"
/>
</template>
<script setup lang="ts">
import { getAuthors } from "oj/api"
interface Props {
all?: boolean
}
const { all = false } = defineProps<Props>()
const author = defineModel<string>("value")
const authorOptions = ref([{ label: "全部", value: "" }])
async function getAuthorOptions() {
authorOptions.value = [{ label: "全部", value: "" }]
const res = await getAuthors(all)
const remotes = res.data.map(
(item: { username: string; problem_count: number }) => ({
label: `${item.username} (${item.problem_count})`,
value: item.username,
}),
)
authorOptions.value = [...authorOptions.value, ...remotes]
}
</script>

View File

@@ -0,0 +1,69 @@
<template>
<n-flex
v-if="!hiddenICP"
justify="center"
align="center"
:size="isMobile ? 4 : 'medium'"
:wrap="false"
class="beian"
:class="{ 'beian--mobile': isMobile }"
>
<n-flex justify="center" align="center" :size="isMobile ? 4 : 'small'">
<n-text>{{ copyrightText }}</n-text>
<n-button text @click="goCC">CC BY-NC 4.0</n-button>
</n-flex>
<template v-if="!isMobile">
<n-button text @click="goICP">浙ICP备2023044109号-1</n-button>
<n-button text @click="goPublicSecurity">
浙公网安备33100402331786号
</n-button>
</template>
</n-flex>
</template>
<script lang="ts" setup>
import { useBreakpoints } from "shared/composables/breakpoints"
const route = useRoute()
const { isMobile } = useBreakpoints()
const hiddenICP = computed(() =>
["problem", "contest problem"].includes(route.name as string),
)
const currentYear = new Date().getFullYear()
const copyrightText = `© 2022 - ${currentYear} 判题狗 保留所有权利`
function goICP() {
window.open("https://beian.miit.gov.cn", "_blank")
}
function goCC() {
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",
)
}
</script>
<style scoped>
.beian {
margin: 12px 0;
}
.beian--mobile {
font-size: 12px;
max-width: 100%;
overflow-x: auto;
padding: 0 8px;
}
.beian--mobile :deep(.n-button__content),
.beian--mobile :deep(.n-text) {
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,65 @@
<script lang="ts" setup>
import { cpp } from "@codemirror/lang-cpp"
import { python } from "@codemirror/lang-python"
import { sql, SQLite } from "@codemirror/lang-sql"
import { bracketMatching } from "@codemirror/language"
import { Codemirror } from "vue-codemirror"
import {
autocompletion,
closeBrackets,
completeAnyWord,
} from "@codemirror/autocomplete"
import type { LANGUAGE } from "utils/types"
import { oneDark } from "../themes/oneDark"
import { smoothy } from "../themes/smoothy"
import { styleTheme } from "shared/extensions/baseTheme"
import { enhanceCompletion } from "shared/extensions/autocompletion"
interface Props {
language?: LANGUAGE
fontSize?: number
height?: string
readonly?: boolean
placeholder?: string
}
const {
language = "Python3",
fontSize = 20,
height = "100%",
readonly = false,
placeholder = "",
} = defineProps<Props>()
const code = defineModel<string>("value")
const isDark = useDark()
const langExtension = computed(() => {
if (language === "SQL")
return sql({ dialect: SQLite, upperCaseKeywords: true })
return ["Python2", "Python3"].includes(language) ? python() : cpp()
})
const extensions = computed(() => [
styleTheme,
langExtension.value,
bracketMatching(),
closeBrackets(),
autocompletion({
override: [enhanceCompletion(language), completeAnyWord],
}),
isDark.value ? oneDark : smoothy,
])
</script>
<template>
<Codemirror
v-model="code"
indentWithTab
:extensions="extensions"
:disabled="readonly"
:tab-size="4"
:placeholder="placeholder"
:style="{ height, fontSize: `${fontSize}px` }"
/>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { ContestType } from "utils/constants"
import type { Contest } from "utils/types"
defineProps<{ contest: Contest }>()
</script>
<template>
<n-flex>
<Icon
v-if="contest.contest_type === ContestType.private"
:height="24"
icon="streamline-ultimate-color:shield-lock"
></Icon>
<span>{{ contest.title }}</span>
</n-flex>
</template>
<style scoped></style>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { ContestType } from "utils/constants"
import type { Contest } from "utils/types"
interface Props {
contest: Contest
size?: "small"
}
const props = defineProps<Props>()
const isPrivate = computed(
() => props.contest.contest_type === ContestType.private,
)
</script>
<template>
<n-tag :type="isPrivate ? 'error' : 'info'" :size="props.size">
{{ isPrivate ? "需要密码" : "公开" }}
</n-tag>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { copyToClipboard } from "utils/functions"
defineProps<{ value: string }>()
const [copied, toggle] = useToggle()
const { start } = useTimeoutFn(() => toggle(false), 1000, { immediate: false })
const COPY = h(Icon, { icon: "fluent-emoji:clipboard" })
const OK = h(Icon, { icon: "fluent-emoji:check-mark-button" })
async function handleClick(value: string) {
const success = await copyToClipboard(value)
if (success) {
toggle(true)
start()
}
}
</script>
<template>
<n-tooltip trigger="hover">
<template #trigger>
<n-icon class="icon" @click="handleClick(value)">
<component :is="copied ? OK : COPY"></component>
</n-icon>
</template>
{{ copied ? "已复制" : "复制" }}
</n-tooltip>
</template>
<style scoped>
.icon {
cursor: pointer;
font-size: 20px;
}
</style>

View File

@@ -0,0 +1,275 @@
<template>
<div
class="custom-node"
:class="{ 'is-hovered': isHovered, 'is-editing': isEditing }"
:data-node-type="nodeType"
:draggable="!isEditing"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@dblclick="handleDoubleClick"
@dragstart="handleDragStart"
@mousedown="handleMouseDown"
>
<!-- 连线点 - 根据节点类型动态显示 -->
<NodeHandles :node-type="nodeType" :node-config="nodeConfig" />
<!-- 节点内容 -->
<div class="node-content">
<!-- 显示模式 -->
<span v-if="!isEditing" class="node-label">{{ displayLabel }}</span>
<!-- 编辑模式 -->
<input
v-if="isEditing"
ref="editInput"
v-model="editText"
class="node-input"
@blur="handleSaveEdit"
@keydown.enter="handleSaveEdit"
@keydown.escape="handleCancelEdit"
@click.stop
/>
<!-- 隐藏的文字用于保持尺寸 -->
<span v-if="isEditing" class="node-label-hidden" aria-hidden="true">
{{ displayLabel }}
</span>
</div>
<!-- 悬停时显示的操作按钮 -->
<NodeActions
v-if="isHovered"
@delete="handleDelete"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, onUnmounted, nextTick, computed, watch } from "vue"
import { getNodeTypeConfig } from "./useNodeStyles"
import NodeHandles from "./NodeHandles.vue"
import NodeActions from "./NodeActions.vue"
interface NodeData {
label: string
color: string
originalType: string
customLabel?: string
}
interface Props {
id: string
type: string
data: NodeData
}
interface Emits {
delete: [nodeId: string]
update: [nodeId: string, newLabel: string]
}
// Props 和 Emits
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 响应式状态
const isHovered = ref(false)
const isEditing = ref(false)
const editText = ref("")
const editInput = useTemplateRef<HTMLInputElement>("editInput")
// 定时器和事件处理器
let hideTimeout: ReturnType<typeof setTimeout> | null = null
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 handleDelete = () => emit("delete", props.id)
const handleMouseDown = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.closest(".vue-flow__handle")) {
event.preventDefault()
}
}
const handleDragStart = (event: DragEvent) => {
if (isEditing.value) return
const target = event.target as HTMLElement
if (target.closest(".vue-flow__handle")) {
event.preventDefault()
return
}
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move"
}
}
const handleDoubleClick = (event: MouseEvent) => {
event.stopPropagation()
if (!isEditing.value) {
isEditing.value = true
editText.value = displayLabel.value
nextTick(() => {
editInput.value?.focus()
editInput.value?.select()
})
addGlobalClickHandler()
}
}
const handleSaveEdit = () => {
if (isEditing.value) {
emit("update", props.id, editText.value.trim())
isEditing.value = false
removeGlobalClickHandler()
}
}
const handleCancelEdit = () => {
isEditing.value = false
editText.value = ""
removeGlobalClickHandler()
}
const handleMouseEnter = () => {
isHovered.value = true
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
}
const handleMouseLeave = () => {
if (hideTimeout) {
clearTimeout(hideTimeout)
}
hideTimeout = setTimeout(() => {
isHovered.value = false
}, 300)
}
// 全局点击处理器
const addGlobalClickHandler = () => {
if (globalClickHandler) return
globalClickHandler = (event: MouseEvent) => {
if (
isEditing.value &&
!(event.target as Element)?.closest(".custom-node")
) {
handleSaveEdit()
}
}
document.addEventListener("click", globalClickHandler, { capture: true })
}
const removeGlobalClickHandler = () => {
if (globalClickHandler) {
document.removeEventListener("click", globalClickHandler, { capture: true })
globalClickHandler = null
}
}
// 清理函数
onUnmounted(() => {
if (hideTimeout) {
clearTimeout(hideTimeout)
}
removeGlobalClickHandler()
})
</script>
<style scoped>
/* 主容器 */
.custom-node {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: inherit;
transition: all 0.2s ease;
padding: 0 20px;
}
.custom-node.is-hovered {
z-index: 1;
}
.custom-node.is-hovered .node-content {
filter: brightness(1.1);
}
/* 节点内容区域 */
.node-content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
height: 100%;
border-radius: inherit;
transition: all 0.2s ease;
}
/* 节点标签 */
.node-label {
font-size: 16px;
font-weight: 500;
white-space: nowrap;
display: inline-block;
text-align: center;
line-height: 1.2;
}
/* 编辑输入框 */
.node-input {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
border: none;
outline: none;
font-size: 16px;
font-weight: 500;
color: inherit;
text-align: center;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
box-sizing: border-box;
white-space: nowrap;
overflow: hidden;
resize: none;
font-family: inherit;
line-height: 1.2;
}
/* 隐藏标签(用于保持尺寸) */
.node-label-hidden {
font-size: 16px;
font-weight: 500;
white-space: nowrap;
display: inline-block;
visibility: hidden;
pointer-events: none;
text-align: center;
line-height: 1.2;
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<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"
/>
</svg>
</button>
</div>
</template>
<script lang="ts" setup>
defineEmits<{
delete: []
mouseenter: []
mouseleave: []
}>()
</script>
<style scoped>
.node-actions {
position: absolute;
top: -40px;
right: -20px;
padding: 8px;
}
.action-btn {
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
font-size: 10px;
}
.delete-btn {
background: #ef4444;
color: white;
}
.delete-btn:hover {
background: #dc2626;
transform: scale(1.05);
}
</style>

View File

@@ -0,0 +1,238 @@
<template>
<!-- 开始节点只有输出 handle -->
<template v-if="nodeType === 'start'">
<Handle
type="source"
id="output"
:position="Position.Bottom"
:style="getHandleStyle('#10b981', { bottom: '-10px' })"
/>
</template>
<!-- 结束节点只有输入 handle -->
<template v-else-if="nodeType === 'end'">
<Handle
type="target"
id="input"
:position="Position.Top"
:style="getHandleStyle('#ef4444', { top: '-10px' })"
/>
</template>
<!-- 选择判断节点一个输入 + 两个输出/ -->
<template v-else-if="nodeType === 'decision'">
<Handle
type="target"
id="input"
:position="Position.Top"
:style="getHandleStyle('#f59e0b', { top: '-16px' })"
/>
<Handle
type="source"
:position="Position.Left"
id="yes"
:style="{
background: '#10b981',
width: '12px',
height: '12px',
border: '2px solid white',
zIndex: 10,
left: '-10px',
top: '50%',
transform: 'translateY(-50%)',
}"
/>
<Handle
type="source"
:position="Position.Right"
id="no"
:style="{
background: '#ef4444',
width: '12px',
height: '12px',
border: '2px solid white',
zIndex: 10,
right: '-10px',
top: '50%',
transform: 'translateY(-50%)',
}"
/>
<!-- /否标签 -->
<div class="decision-labels">
<span class="decision-label decision-label-yes"></span>
<span class="decision-label decision-label-no"></span>
</div>
</template>
<!-- 循环判断节点两个输入 + 两个输出进入/循环体返回 + 继续/退出 -->
<template v-else-if="nodeType === 'loop'">
<!-- 进入循环的输入 -->
<Handle
type="target"
:position="Position.Top"
id="enter"
:style="getHandleStyle('#f59e0b', { top: '-16px' })"
/>
<!-- 循环体返回的输入 -->
<Handle
type="target"
:position="Position.Bottom"
id="return"
:style="
getHandleStyle('#8b5cf6', {
bottom: '-16px',
})
"
/>
<!-- 继续执行循环体 -->
<Handle
type="source"
:position="Position.Right"
id="continue"
:style="
getHandleStyle('#10b981', {
right: '-10px',
top: '50%',
transform: 'translateY(-50%)',
})
"
/>
<!-- 退出循环 -->
<Handle
type="source"
:position="Position.Left"
id="exit"
:style="
getHandleStyle('#ef4444', {
left: '-10px',
top: '50%',
transform: 'translateY(-50%)',
})
"
/>
<!-- 标签 -->
<div class="loop-labels">
<span class="loop-label loop-label-enter">进入</span>
<span class="loop-label loop-label-return">返回</span>
<span class="loop-label loop-label-continue">继续</span>
<span class="loop-label loop-label-exit">退出</span>
</div>
</template>
<!-- 上下两个 handle -->
<template v-else>
<Handle
type="target"
id="input"
:position="Position.Top"
:style="getHandleStyle(nodeConfig.color, { top: '-10px' })"
/>
<Handle
type="source"
id="output"
:position="Position.Bottom"
:style="getHandleStyle(nodeConfig.color, { bottom: '-10px' })"
/>
</template>
</template>
<script lang="ts" setup>
import { Handle, Position } from "@vue-flow/core"
interface Props {
nodeType: string
nodeConfig: {
color: string
label: string
}
}
defineProps<Props>()
// 获取 handle 样式
const getHandleStyle = (color: string, position: Record<string, string>) => ({
background: color,
width: "12px",
height: "12px",
border: "2px solid white",
zIndex: 10,
...position,
})
</script>
<style scoped>
/* 判断节点标签样式 */
.decision-labels {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1000;
}
.decision-label {
position: absolute;
font-size: 16px;
font-weight: 600;
color: #000;
white-space: nowrap;
pointer-events: none;
user-select: none;
}
.decision-label-yes {
left: -25px;
top: -20px;
}
.decision-label-no {
right: -25px;
top: -20px;
}
/* 循环节点标签样式 */
.loop-labels {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.loop-label {
position: absolute;
font-size: 16px;
font-weight: 600;
color: #000;
white-space: nowrap;
pointer-events: none;
user-select: none;
}
.loop-label-enter {
right: 20px;
top: -45px;
}
.loop-label-return {
right: 20px;
bottom: -45px;
}
.loop-label-continue {
right: -40px;
top: -16px;
transform: translateY(-50%);
}
.loop-label-exit {
left: -40px;
top: -16px;
transform: translateY(-50%);
}
</style>

View File

@@ -0,0 +1,413 @@
<script setup lang="ts">
import { computed } from "vue"
import { getNodeTypeConfig } from "./useNodeStyles"
import { currentDragNodeType } from "./useDnD"
// 拖拽开始处理
const onDragStart = (event: DragEvent, type: string) => {
if (!event.dataTransfer || !type) return
event.dataTransfer.setData("application/vueflow", type)
event.dataTransfer.effectAllowed = "move"
currentDragNodeType.value = type
// 隐藏浏览器默认拖影,改用 canvas 跟随预览
const emptyImg = new Image(1, 1)
emptyImg.src =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
event.dataTransfer.setDragImage(emptyImg, 0, 0)
}
const onDragEnd = () => {
currentDragNodeType.value = null
}
// Props
const props = defineProps<{
canUndo?: boolean
canRedo?: boolean
isSaving?: boolean
lastSaved?: Date | null
hasUnsavedChanges?: boolean
}>()
// Emits
const emit = defineEmits<{
deleteNode: [nodeId: string]
undo: []
redo: []
clear: []
}>()
// 工具栏状态
// 节点类型定义 - 优化性能
const nodeTypes = computed(() =>
["start", "input", "default", "decision", "loop", "output", "end"].map(
(type) => {
const config = getNodeTypeConfig(type)
return {
type,
...config,
}
},
),
)
const saveStatusTitle = computed(() => {
if (props.isSaving) {
return "正在保存..."
} else if (props.hasUnsavedChanges) {
return "有未保存的更改"
} else if (props.lastSaved) {
return `已保存 - ${new Date(props.lastSaved).toLocaleTimeString()}`
} else {
return "已保存"
}
})
</script>
<template>
<div class="toolbar">
<!-- 工具栏头部 -->
<div class="toolbar-header">
<div class="header-content">
<h3>节点库</h3>
<div
class="save-status-indicator"
:class="{
saving: props.isSaving,
unsaved: props.hasUnsavedChanges && !props.isSaving,
saved: !props.hasUnsavedChanges && !props.isSaving,
}"
:title="saveStatusTitle"
>
<span v-if="props.isSaving" class="spinner"></span>
<span v-else-if="props.hasUnsavedChanges"></span>
<span v-else></span>
</div>
</div>
<p class="description">拖拽节点到画布中</p>
</div>
<!-- 节点列表 -->
<div class="nodes">
<div
v-for="nodeType in nodeTypes"
:key="nodeType.type"
class="node-item"
:draggable="true"
@dragstart="onDragStart($event, nodeType.type)"
@dragend="onDragEnd"
:style="{ borderColor: nodeType.color }"
:title="`${nodeType.label} - ${nodeType.description}`"
>
<div class="node-icon" :style="{ backgroundColor: nodeType.color }">
{{ nodeType.icon }}
</div>
<div class="node-info">
<div class="node-label">{{ nodeType.label }}</div>
<div class="node-description">{{ nodeType.description }}</div>
</div>
</div>
</div>
<!-- 工具栏操作 -->
<div class="toolbar-actions">
<div class="history-controls">
<button
class="action-btn history-btn"
:disabled="!canUndo"
@click="$emit('undo')"
title="撤销 (Ctrl+Z)"
>
<span class="btn-icon"></span>
<span class="btn-text">撤销</span>
</button>
<button
class="action-btn history-btn"
:disabled="!canRedo"
@click="$emit('redo')"
title="重做 (Ctrl+Y)"
>
<span class="btn-icon"></span>
<span class="btn-text">重做</span>
</button>
</div>
<button
class="action-btn clear-btn"
@click="$emit('clear')"
title="清空画布"
>
<span class="btn-icon">🗑</span>
<span class="btn-text">清空画布</span>
</button>
</div>
</div>
</template>
<style scoped>
.toolbar {
width: 140px;
height: auto;
max-height: calc(100vh - 40px);
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
padding: 16px;
overflow-y: auto;
transition: all 0.3s ease;
}
.toolbar-header {
margin-bottom: 16px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 12px;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.toolbar-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
/* 保存状态指示器样式 */
.save-status-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 12px;
font-weight: bold;
transition: all 0.3s ease;
cursor: pointer;
}
.save-status-indicator.saving {
background: #fef3c7;
color: #d97706;
animation: pulse 1.5s ease-in-out infinite;
}
.save-status-indicator.unsaved {
background: #fef2f2;
color: #dc2626;
animation: pulse 1.5s ease-in-out infinite;
}
.save-status-indicator.saved {
background: #f0fdf4;
color: #16a34a;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.description {
margin: 0;
font-size: 12px;
color: #6b7280;
}
/* 节点列表样式 */
.nodes {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.node-item {
display: flex;
align-items: center;
padding: 10px;
border: 2px solid #e5e7eb;
border-radius: 8px;
cursor: grab;
transition: all 0.2s ease;
background: white;
}
.node-item:hover {
border-color: #3b82f6;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
transform: translateY(-2px);
}
.node-item:active {
cursor: grabbing;
transform: translateY(0);
}
.node-icon {
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: white;
margin-right: 10px;
flex-shrink: 0;
}
.node-info {
flex: 1;
min-width: 0;
}
.node-label {
font-weight: 500;
color: #1f2937;
margin-bottom: 2px;
font-size: 14px;
}
.node-description {
font-size: 12px;
color: #6b7280;
line-height: 1.3;
}
/* 工具栏操作按钮样式 */
.toolbar-actions {
display: flex;
flex-direction: column;
gap: 8px;
border-top: 1px solid #e5e7eb;
padding-top: 12px;
}
.history-controls {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
color: #374151;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
gap: 6px;
}
.action-btn:hover {
background: #f9fafb;
border-color: #9ca3af;
transform: translateY(-1px);
}
.action-btn:active {
background: #f3f4f6;
transform: translateY(0);
}
.history-btn {
flex: 1;
font-size: 11px;
padding: 8px 10px;
}
.history-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #f9fafb;
color: #9ca3af;
}
.history-btn:disabled:hover {
background: #f9fafb;
border-color: #d1d5db;
transform: none;
}
.clear-btn {
background: #fef2f2;
border-color: #fecaca;
color: #dc2626;
}
.clear-btn:hover {
background: #fee2e2;
border-color: #fca5a5;
}
.btn-icon {
font-size: 14px;
}
.btn-text {
font-size: 12px;
}
/* 滚动条样式 */
.toolbar::-webkit-scrollbar {
width: 6px;
}
.toolbar::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
.toolbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.toolbar::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* 响应式设计 */
@media (max-width: 768px) {
.toolbar {
width: 180px;
top: 10px;
left: 10px;
}
}
</style>

View File

@@ -0,0 +1,325 @@
<script lang="ts" setup>
import { ref, onMounted, onUnmounted } from "vue"
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 { Controls } from "@vue-flow/controls"
import { Background } from "@vue-flow/background"
import { useDnD, currentDragNodeType } from "./useDnD"
import { getNodeTypeConfig } from "./useNodeStyles"
import { useHistory } from "./useHistory"
import { useFlowOperations } from "./useFlowOperations"
import { useCache } from "./useCache"
import CustomNode from "./CustomNode.vue"
import { useProblemStore } from "oj/store/problem"
interface Props {
height?: string
}
const { height = "calc(100vh - 133px)" } = defineProps<Props>()
// Vue Flow 实例
const { addEdges, removeNodes, removeEdges } = useVueFlow()
// 节点和边的响应式数据
const nodes = ref<Node[]>([])
const edges = ref<Edge[]>([])
// 历史记录管理
const { canUndo, canRedo, saveState, undo, redo } = useHistory()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
// 缓存管理:用 computed key 支持题目 ID 异步加载后自动切换到正确的 storage
const cacheKey = computed(() =>
problem.value?._id
? `flowchart-editor-data-problem-${problem.value!._id}`
: "flowchart-editor-data",
)
const {
isSaving,
lastSaved,
hasUnsavedChanges,
saveToCache,
loadFromCache,
clearCache,
} = useCache(nodes, edges, cacheKey)
// 拖拽处理
const { onDragOver, onDragLeave, onDrop, isDragOver, screenDragPos } = useDnD()
const dragPreviewStyle = computed(() => {
if (!screenDragPos.value || !currentDragNodeType.value) return null
const config = getNodeTypeConfig(currentDragNodeType.value)
const type = currentDragNodeType.value
return {
left: `${screenDragPos.value.x}px`,
top: `${screenDragPos.value.y}px`,
background: config.color,
borderRadius: type === "start" || type === "end" ? "20px" : "8px",
}
})
// 流程操作
const {
handleConnect,
handleEdgeClick,
handleNodeDelete,
handleNodeUpdate,
clearCanvas,
deleteSelected,
} = useFlowOperations(
nodes,
edges,
addEdges,
removeNodes,
removeEdges,
saveState,
)
const handleDrop = async (event: DragEvent) => {
const newNode = onDrop(event)
if (newNode) {
await nextTick()
saveState(nodes.value, edges.value)
}
}
// 撤销/重做处理
const handleUndo = () => {
const state = undo()
if (state) {
nodes.value = state.nodes
edges.value = state.edges
saveToCache()
}
}
const handleRedo = () => {
const state = redo()
if (state) {
nodes.value = state.nodes
edges.value = state.edges
saveToCache()
}
}
// 清空画布
const handleClear = () => {
clearCanvas()
clearCache()
}
// 键盘事件
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
)
return
if (event.key === "Delete" || event.key === "Backspace") {
deleteSelected()
}
if (event.ctrlKey || event.metaKey) {
if (event.key === "z" && !event.shiftKey) {
event.preventDefault()
handleUndo()
} else if (event.key === "z" && event.shiftKey) {
event.preventDefault()
handleRedo()
}
}
}
onMounted(() => {
document.addEventListener("keydown", handleKeyDown)
// 从缓存恢复数据
loadFromCache()
})
onUnmounted(() => {
document.removeEventListener("keydown", handleKeyDown)
})
// 加载外部数据到编辑器
const setFlowchartData = (data: { nodes: Node[]; edges: Edge[] }) => {
if (data && data.nodes && data.edges) {
// 确保节点数据包含必要的位置信息
const processedNodes = data.nodes.map((node) => ({
...node,
position: node.position || { x: 0, y: 0 },
}))
// 确保边数据包含必要的 handle 信息
const processedEdges = data.edges.map((edge) => ({
...edge,
sourceHandle: edge.sourceHandle || null,
targetHandle: edge.targetHandle || null,
}))
nodes.value = processedNodes
edges.value = processedEdges
saveState(nodes.value, edges.value)
}
}
// 暴露节点和边数据给父组件
defineExpose({
nodes,
edges,
getFlowchartData: () => ({
nodes: nodes.value,
edges: edges.value,
}),
setFlowchartData,
})
</script>
<template>
<div class="container" :style="{ height }">
<!-- 拖拽时跟随鼠标的节点预览 -->
<Transition name="drag-preview">
<div
v-if="isDragOver && dragPreviewStyle && currentDragNodeType"
class="drag-node-preview"
:style="dragPreviewStyle"
>
<span class="preview-icon">{{
getNodeTypeConfig(currentDragNodeType).icon
}}</span>
<span>{{ getNodeTypeConfig(currentDragNodeType).label }}</span>
</div>
</Transition>
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="handleDrop"
@connect="handleConnect"
@edge-click="handleEdgeClick"
:default-edge-options="{
type: 'default',
style: {
stroke: '#6366f1',
strokeWidth: 2.5,
cursor: 'pointer',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#6366f1',
width: 16,
height: 16,
},
}"
:connection-line-style="{
stroke: '#6366f1',
strokeWidth: 2.5,
strokeDasharray: '8,4',
markerEnd: 'url(#connection-arrow)',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
}"
:fit-view-on-init="false"
:connect-on-click="false"
:multi-selection-key-code="null"
:delete-key-code="null"
>
<!-- SVG 定义用于连接线箭头 -->
<defs>
<marker
id="connection-arrow"
markerWidth="12"
markerHeight="12"
refX="10"
refY="3"
orient="auto"
markerUnits="strokeWidth"
>
<path
d="M0,0 L0,6 L10,3 z"
fill="#6366f1"
stroke="#6366f1"
strokeWidth="0.5"
/>
</marker>
</defs>
<template #node-custom="{ data, id, type }">
<CustomNode
:id="id"
:type="type"
:data="data"
@delete="handleNodeDelete"
@update="handleNodeUpdate"
/>
</template>
<Background variant="lines" :gap="20" :size="1" />
<Controls />
<Toolbar
:can-undo="canUndo"
:can-redo="canRedo"
:is-saving="isSaving"
:last-saved="lastSaved"
:has-unsaved-changes="hasUnsavedChanges"
@clear="handleClear"
@undo="handleUndo"
@redo="handleRedo"
@deleteNode="handleNodeDelete"
/>
</VueFlow>
</div>
</template>
<style scoped>
.container {
width: 100%;
height: 100%;
position: relative;
}
.drag-node-preview {
position: fixed;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 9999;
padding: 8px 18px;
color: white;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
opacity: 0.55;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
white-space: nowrap;
border: 2px dashed rgba(255, 255, 255, 0.6);
user-select: none;
}
.preview-icon {
font-size: 16px;
}
.drag-preview-enter-active,
.drag-preview-leave-active {
transition: opacity 0.1s ease;
}
.drag-preview-enter-from,
.drag-preview-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,89 @@
import { ref, watch, type Ref, type MaybeRefOrGetter } from "vue"
import { useStorage, useDebounceFn } from "@vueuse/core"
import type { Node, Edge } from "@vue-flow/core"
/**
* 缓存管理 - 使用 @vueuse 的 useStorage
*/
export function useCache(
nodes: Ref<Node[]>,
edges: Ref<Edge[]>,
storageKey: MaybeRefOrGetter<string> = "flowchart-editor-data",
) {
const isSaving = ref(false)
const lastSaved = ref<Date | null>(null)
const hasUnsavedChanges = ref(false)
// 使用 useStorage 管理数据存储,支持响应式 key题目 ID 异步加载时自动切换)
const storedData = useStorage<{
nodes: Node[]
edges: Edge[]
timestamp: string
}>(storageKey, {
nodes: [],
edges: [],
timestamp: "",
})
// 防抖保存isSaving 在 watch 中置 true保存完成后置 false使 UI 能感知保存中状态
const debouncedSave = useDebounceFn(() => {
storedData.value.nodes = nodes.value
storedData.value.edges = edges.value
storedData.value.timestamp = new Date().toISOString()
lastSaved.value = new Date()
hasUnsavedChanges.value = false
isSaving.value = false
}, 500)
// 立即保存
const saveToCache = () => {
isSaving.value = true
storedData.value.nodes = nodes.value
storedData.value.edges = edges.value
storedData.value.timestamp = new Date().toISOString()
lastSaved.value = new Date()
hasUnsavedChanges.value = false
isSaving.value = false
}
// 从缓存加载数据
const loadFromCache = () => {
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
hasUnsavedChanges.value = false
return true
}
return false
}
// 清除缓存数据
const clearCache = () => {
storedData.value = { nodes: [], edges: [], timestamp: "" }
lastSaved.value = null
hasUnsavedChanges.value = false
}
// 监听节点和边的变化isSaving 在此置 true 以覆盖防抖等待窗口
watch(
[nodes, edges],
() => {
hasUnsavedChanges.value = true
isSaving.value = true
debouncedSave()
},
{ deep: true },
)
return {
isSaving,
lastSaved,
hasUnsavedChanges,
saveToCache,
loadFromCache,
clearCache,
}
}

View File

@@ -0,0 +1,84 @@
import { ref } from "vue"
import { useVueFlow } from "@vue-flow/core"
import {
getNodeTypeConfig,
createNodeStyle,
getNodeDimensions,
} from "./useNodeStyles"
import { getRandomId } from "utils/functions"
// 模块级共享当前拖拽的节点类型Toolbar 写入canvas 读取)
export const currentDragNodeType = ref<string | null>(null)
/**
* 简化的拖拽处理
*/
export function useDnD() {
const { addNodes, screenToFlowCoordinate } = useVueFlow()
const isDragOver = ref(false)
const screenDragPos = ref<{ x: number; y: number } | null>(null)
// 拖拽悬停处理
const onDragOver = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = true
screenDragPos.value = { x: event.clientX, y: event.clientY }
}
// 拖拽离开处理
const onDragLeave = () => {
isDragOver.value = false
screenDragPos.value = null
}
// 拖拽放置处理
const onDrop = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = false
screenDragPos.value = null
currentDragNodeType.value = null
const type = event.dataTransfer?.getData("application/vueflow")
if (!type) return
// 获取鼠标在画布中的坐标
const position = screenToFlowCoordinate({
x: event.clientX,
y: event.clientY,
})
// 根据节点类型获取实际尺寸
const dimensions = getNodeDimensions(type)
// 调整位置,使节点中心点对齐到鼠标位置
const adjustedPosition = {
x: position.x - dimensions.width / 2,
y: position.y - dimensions.height / 2,
}
const nodeId = `node-${getRandomId()}`
const config = getNodeTypeConfig(type)
const newNode = {
id: nodeId,
type: "custom",
position: adjustedPosition,
data: {
label: config.label,
color: config.color,
originalType: type,
},
style: createNodeStyle(type),
}
addNodes([newNode])
return newNode
}
return {
isDragOver,
screenDragPos,
onDragOver,
onDragLeave,
onDrop,
}
}

View File

@@ -0,0 +1,142 @@
import type { Ref } from "vue"
import type { Node, Edge, Connection } from "@vue-flow/core"
import { useVueFlow } from "@vue-flow/core"
import { getRandomId } from "utils/functions"
export function useFlowOperations(
nodes: Ref<Node[]>,
edges: Ref<Edge[]>,
addEdges: (edges: Edge[]) => void,
removeNodes: (nodeIds: string[]) => void,
removeEdges: (edgeIds: string[]) => void,
saveState: (nodes: Node[], edges: Edge[]) => void,
) {
const { findNode, getSelectedNodes, getSelectedEdges } = useVueFlow()
const getAutoLabel = (
sourceNode: Node | undefined,
targetNode: Node | undefined,
sourceHandle: string | null | undefined,
targetHandle: string | null | undefined,
) => {
const sourceType = sourceNode?.data?.originalType || sourceNode?.type
const targetType = targetNode?.data?.originalType || targetNode?.type
// 如果是判断节点
if (sourceType === "decision") {
// 根据handle ID推断标签
if (sourceHandle === "yes") {
return "是"
} else if (sourceHandle === "no") {
return "否"
}
}
// 如果是循环节点
if (sourceType === "loop") {
// 根据handle ID推断标签
if (sourceHandle === "continue") {
return "继续"
} else if (sourceHandle === "exit") {
return "退出"
}
}
// 如果是循环体回到循环节点
if (targetType === "loop") {
if (targetHandle === "return") {
return "返回"
}
}
// 默认情况
return ""
}
const handleConnect = (params: Connection) => {
const sourceNode = nodes.value.find((node) => node.id === params.source)
const targetNode = nodes.value.find((node) => node.id === params.target)
// 自动推断标签
const autoLabel = getAutoLabel(
sourceNode,
targetNode,
params.sourceHandle,
params.targetHandle,
)
const newEdge: Edge = {
id: `edge-${getRandomId()}`,
source: params.source,
target: params.target,
sourceHandle: params.sourceHandle,
targetHandle: params.targetHandle,
type: "default",
label: autoLabel,
}
addEdges([newEdge])
saveState(nodes.value, edges.value)
}
const handleEdgeClick = ({ edge }: { edge: Edge }) => {
removeEdges([edge.id])
saveState(nodes.value, edges.value)
}
// 节点删除
const handleNodeDelete = (nodeId: string) => {
// 删除相关边
const relatedEdges = edges.value.filter(
(edge) => edge.source === nodeId || edge.target === nodeId,
)
if (relatedEdges.length > 0) {
removeEdges(relatedEdges.map((edge) => edge.id))
}
removeNodes([nodeId])
saveState(nodes.value, edges.value)
}
// 节点更新,空标签时清除自定义标签(恢复默认类型名称)
const handleNodeUpdate = (nodeId: string, newLabel: string) => {
const node = findNode(nodeId)
if (node) {
if (newLabel) {
node.data = { ...node.data, customLabel: newLabel }
} else {
const { customLabel: _, ...rest } = node.data
node.data = rest
}
saveState(nodes.value, edges.value)
}
}
// 清空画布
const clearCanvas = () => {
nodes.value = []
edges.value = []
saveState(nodes.value, edges.value)
}
// 删除选中的节点和边
const deleteSelected = () => {
const selectedNodes = getSelectedNodes.value
const selectedEdges = getSelectedEdges.value
if (selectedNodes.length > 0) {
removeNodes(selectedNodes.map((node) => node.id))
}
if (selectedEdges.length > 0) {
removeEdges(selectedEdges.map((edge) => edge.id))
}
saveState(nodes.value, edges.value)
}
return {
handleConnect,
handleEdgeClick,
handleNodeDelete,
handleNodeUpdate,
clearCanvas,
deleteSelected,
}
}

View File

@@ -0,0 +1,72 @@
import { shallowRef, computed } from "vue"
import type { Node, Edge } from "@vue-flow/core"
/**
* 简化的历史记录管理
*/
export function useHistory() {
const history = shallowRef<{ nodes: Node[]; edges: Edge[] }[]>([])
const historyIndex = ref(-1)
// 是否可以撤销
const canUndo = computed(() => historyIndex.value > 0)
// 是否可以重做
const canRedo = computed(() => historyIndex.value < history.value.length - 1)
const deepCopyState = (
nodes: Node[],
edges: Edge[],
): { nodes: Node[]; edges: Edge[] } =>
JSON.parse(JSON.stringify({ nodes, edges })) as {
nodes: Node[]
edges: Edge[]
}
// 保存状态到历史记录
const saveState = (nodes: Node[], edges: Edge[]) => {
const currentState = deepCopyState(nodes, edges)
// 如果当前不在历史记录的末尾,删除后面的记录
if (historyIndex.value < history.value.length - 1) {
history.value = history.value.slice(0, historyIndex.value + 1)
}
history.value = [...history.value, currentState]
historyIndex.value = history.value.length - 1
// 限制历史记录数量
if (history.value.length > 20) {
history.value = history.value.slice(1)
historyIndex.value--
}
}
// 撤销
const undo = () => {
if (canUndo.value) {
historyIndex.value--
const state = history.value[historyIndex.value]
return deepCopyState(state.nodes, state.edges)
}
return null
}
// 重做
const redo = () => {
if (canRedo.value) {
historyIndex.value++
const state = history.value[historyIndex.value]
return deepCopyState(state.nodes, state.edges)
}
return null
}
return {
canUndo,
canRedo,
saveState,
undo,
redo,
}
}

View File

@@ -0,0 +1,137 @@
/**
* 节点样式管理
*/
/**
* 获取节点类型配置
*/
export function getNodeTypeConfig(type: string) {
const configs: Record<
string,
{ label: string; color: string; icon: string; description: string }
> = {
start: {
label: "开始",
color: "#10b981",
icon: "▶",
description: "流程开始",
},
input: {
label: "输入",
color: "#06b6d4",
icon: "📥",
description: "数据输入",
},
default: {
label: "赋值",
color: "#3b82f6",
icon: "⚙",
description: "赋值语句",
},
decision: {
label: "判断",
color: "#f59e0b",
icon: "❓",
description: "条件语句",
},
loop: {
label: "循环",
color: "#8b5cf6",
icon: "🔄",
description: "循环语句",
},
output: {
label: "输出",
color: "#84cc16",
icon: "📤",
description: "数据输出",
},
end: {
label: "结束",
color: "#ef4444",
icon: "⏹",
description: "流程结束",
},
}
return (
configs[type] || {
label: "节点",
color: "#6b7280",
icon: "⚪",
description: "未知节点",
}
)
}
/**
* 获取节点样式
*/
export function getNodeStyle(type: string, color: string) {
const baseStyle = {
background: color,
color: "white",
border: `2px solid ${color}`,
borderRadius: "10px",
fontSize: "16px",
fontWeight: "500",
width: "auto", // 自动宽度
height: "auto", // 自动高度
minWidth: "100px",
minHeight: "40px",
maxWidth: "400px",
maxHeight: "160px",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
}
// 根据节点类型调整样式
switch (type) {
case "start":
case "end":
return {
...baseStyle,
}
case "decision":
return {
...baseStyle,
borderRadius: "8px",
minWidth: "140px",
minHeight: "50px",
}
case "loop":
return {
...baseStyle,
borderRadius: "8px",
minWidth: "140px",
minHeight: "50px",
}
default:
return baseStyle
}
}
/**
* 创建节点样式
*/
export function createNodeStyle(type: string) {
const config = getNodeTypeConfig(type)
return getNodeStyle(type, config.color)
}
/**
* 获取节点尺寸
*/
export function getNodeDimensions(type: string) {
switch (type) {
case "start":
case "end":
return { width: 100, height: 40 }
case "decision":
case "loop":
return { width: 140, height: 50 }
default:
return { width: 120, height: 40 }
}
}

View File

@@ -0,0 +1,577 @@
<template>
<n-flex align="center">
<n-input
placeholder="用户(可选)"
v-model:value="query.username"
style="width: 150px"
clearable
/>
<n-input
placeholder="题号(可选)"
v-model:value="query.problem"
style="width: 120px"
clearable
/>
<n-select
style="width: 120px"
v-model:value="query.duration"
:options="durationOptions"
/>
<n-button type="primary" @click="handleStatistics">统计</n-button>
</n-flex>
<n-empty
v-if="data.total_count === 0"
description="暂无数据"
style="margin: 40px 0"
/>
<template v-if="data.total_count > 0">
<n-divider style="margin: 16px 0" />
<n-flex justify="space-around">
<div class="stat-item">
<n-text>总提交</n-text>
<n-gradient-text type="info" font-size="28">
{{ data.total_count }}
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>平均分</n-text>
<n-gradient-text type="primary" font-size="28">
{{ data.avg_score }}
</n-gradient-text>
</div>
<template v-if="data.person_count > 0">
<div class="stat-item">
<n-text>完成人数</n-text>
<n-gradient-text type="error" font-size="28">
{{ data.completed_count }}
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>班级人数</n-text>
<n-gradient-text type="warning" font-size="28">
{{ data.person_count }}
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>完成度</n-text>
<n-gradient-text type="success" font-size="28">
{{ completionRate }}
</n-gradient-text>
</div>
</template>
</n-flex>
<n-divider style="margin: 16px 0" />
<n-tabs animated type="line">
<n-tab-pane name="charts" tab="数据图表">
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 12px">
<!-- 1. Grade pie chart -->
<n-gi>
<n-card title="等级分布">
<div class="chart-container">
<Doughnut :data="gradeChartData" :options="doughnutOptions" />
</div>
</n-card>
</n-gi>
<!-- 3. Completion doughnut -->
<n-gi v-if="data.person_count > 0">
<n-card title="班级完成度">
<div class="chart-container">
<Doughnut
:data="completionChartData"
:options="doughnutOptions"
/>
</div>
</n-card>
</n-gi>
<!-- 2. Radar chart -->
<n-gi v-if="hasRadarData">
<n-card title="四维评分雷达图">
<div class="chart-container">
<Radar :data="radarChartData" :options="radarOptions" />
</div>
</n-card>
</n-gi>
<!-- 4. Criteria bar chart (only when class exists, pairs with radar) -->
<n-gi v-if="data.person_count > 0 && hasRadarData">
<n-card title="各维度平均得分">
<div class="chart-container">
<Bar :data="criteriaBarChartData" :options="barOptions" />
</div>
</n-card>
</n-gi>
<!-- 4. Word cloud -->
<n-gi :span="2" v-if="data.word_frequencies.length > 0">
<n-card title="常见问题高频词">
<div class="wordcloud-container">
<canvas ref="wordcloudCanvas"></canvas>
</div>
</n-card>
</n-gi>
</n-grid>
</n-tab-pane>
<n-tab-pane
v-if="data.data_unaccepted.length > 0"
name="unaccepted"
:tab="`未完成(${visibleUnaccepted.length}`"
>
<n-flex align="center" style="margin: 12px 0">
<n-switch v-model:value="hideMode" size="large">
<template #checked>请假隐藏中</template>
<template #unchecked>请假隐藏</template>
</n-switch>
<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>
<template v-for="item in visibleUnaccepted" :key="item.username">
<n-tag
v-if="hideMode"
closable
size="large"
style="font-size: 20px"
@close="hideStudent(item.username)"
>
{{ item.real_name }}
</n-tag>
<span v-else style="font-size: 24px">{{ item.real_name }}</span>
</template>
</n-flex>
</n-tab-pane>
</n-tabs>
</template>
</template>
<script setup lang="ts">
import { formatISO, sub, type Duration } from "date-fns"
import { getFlowchartStatistics } from "oj/api"
import { DURATION_OPTIONS } from "utils/constants"
import { Doughnut, Radar, Bar } from "vue-chartjs"
import {
Chart as ChartJS,
ArcElement,
Title,
Tooltip,
Legend,
RadialLinearScale,
PointElement,
LineElement,
Filler,
LinearScale,
BarElement,
CategoryScale,
} from "chart.js"
import { WordCloudController, WordElement } from "chartjs-chart-wordcloud"
ChartJS.register(
ArcElement,
Title,
Tooltip,
Legend,
RadialLinearScale,
PointElement,
LineElement,
Filler,
LinearScale,
BarElement,
CategoryScale,
WordCloudController,
WordElement,
)
interface Props {
problem: string
username: string
}
const props = defineProps<Props>()
const durationOptions: SelectOption[] = [
{ label: "10分钟内", value: "minutes:10" },
{ label: "20分钟内", value: "minutes:20" },
{ label: "30分钟内", value: "minutes:30" },
...DURATION_OPTIONS,
{ label: "全部时段", value: "all" },
]
const query = reactive({
username: props.username,
problem: props.problem,
duration: durationOptions[0].value,
})
interface StatisticsData {
total_count: number
avg_score: number
grade_distribution: Record<string, number>
criteria_averages: Record<string, { avg: number; max: number }>
person_count: number
completed_count: number
word_frequencies: { word: string; count: number }[]
data_unaccepted: { username: string; real_name: string }[]
}
const data = reactive<StatisticsData>({
total_count: 0,
avg_score: 0,
grade_distribution: {},
criteria_averages: {},
person_count: 0,
completed_count: 0,
word_frequencies: [],
data_unaccepted: [],
})
const wordcloudCanvas = useTemplateRef<HTMLCanvasElement>("wordcloudCanvas")
let wordcloudChart: ChartJS | null = null
const HIDE_DURATION = 2 * 60 * 60 * 1000
const STORAGE_KEY = "oj_hidden_students_flowchart"
function loadHidden(): Record<string, number> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")
} catch {
return {}
}
}
const hiddenStudents = ref<Record<string, number>>(loadHidden())
const hideMode = ref(false)
function saveHidden(d: Record<string, number>) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(d))
}
function hideStudent(username: string) {
hiddenStudents.value = {
...hiddenStudents.value,
[username]: Date.now() + HIDE_DURATION,
}
saveHidden(hiddenStudents.value)
}
function showAll() {
hiddenStudents.value = {}
saveHidden({})
}
const visibleUnaccepted = computed(() => {
const now = Date.now()
return data.data_unaccepted.filter((item) => {
const exp = hiddenStudents.value[item.username]
return !exp || exp <= now
})
})
const hiddenCount = computed(() => {
const now = Date.now()
return data.data_unaccepted.filter((item) => {
const exp = hiddenStudents.value[item.username]
return !!exp && exp > now
}).length
})
const adjustedPersonCount = computed(() =>
Math.max(0, data.person_count - hiddenCount.value),
)
onMounted(() => {
const now = Date.now()
const cleaned = Object.fromEntries(
Object.entries(hiddenStudents.value).filter(([, exp]) => exp > now),
)
hiddenStudents.value = cleaned
saveHidden(cleaned)
})
const completionRate = computed(() => {
if (adjustedPersonCount.value <= 0) return "0%"
const rate = Math.min(
100,
(data.completed_count / adjustedPersonCount.value) * 100,
)
return `${Math.round(rate * 100) / 100}%`
})
const GRADE_COLORS: Record<string, { bg: string; border: string }> = {
S: { bg: "rgba(24, 160, 88, 0.6)", border: "rgba(24, 160, 88, 1)" },
A: { bg: "rgba(32, 128, 240, 0.6)", border: "rgba(32, 128, 240, 1)" },
B: { bg: "rgba(240, 160, 32, 0.6)", border: "rgba(240, 160, 32, 1)" },
C: { bg: "rgba(208, 48, 80, 0.6)", border: "rgba(208, 48, 80, 1)" },
}
const gradeChartData = computed(() => {
const grades = ["S", "A", "B", "C"]
const counts = grades.map((g) => data.grade_distribution[g] || 0)
const labels = grades.map(
(g) => `${g}级 (${data.grade_distribution[g] || 0})`,
)
return {
labels,
datasets: [
{
data: counts,
backgroundColor: grades.map((g) => GRADE_COLORS[g].bg),
borderColor: grades.map((g) => GRADE_COLORS[g].border),
borderWidth: 2,
},
],
}
})
const completionChartData = computed(() => {
const uncompleted = Math.max(
0,
adjustedPersonCount.value - data.completed_count,
)
return {
labels: ["已完成", "未完成"],
datasets: [
{
data: [data.completed_count, uncompleted],
backgroundColor: ["rgba(106, 176, 76, 0.6)", "rgba(255, 159, 64, 0.6)"],
borderColor: ["rgba(106, 176, 76, 1)", "rgba(255, 159, 64, 1)"],
borderWidth: 2,
},
],
}
})
const doughnutOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: "bottom" as const },
tooltip: {
callbacks: {
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 pct = ((value / total) * 100).toFixed(1)
return `${label}: ${value} (${pct}%)`
},
},
},
},
}
const CRITERIA_ORDER = ["逻辑正确性", "完整性", "规范性", "清晰度"]
const hasRadarData = computed(() =>
CRITERIA_ORDER.some((k) => k in data.criteria_averages),
)
const radarChartData = computed(() => {
const labels = CRITERIA_ORDER
const values = CRITERIA_ORDER.map((k) => {
const item = data.criteria_averages[k]
if (!item) return 0
return Math.round((item.avg / item.max) * 100)
})
return {
labels,
datasets: [
{
label: "平均得分率 (%)",
data: values,
backgroundColor: "rgba(32, 128, 240, 0.2)",
borderColor: "rgba(32, 128, 240, 1)",
borderWidth: 2,
pointBackgroundColor: "rgba(32, 128, 240, 1)",
},
],
}
})
const radarOptions = {
responsive: true,
maintainAspectRatio: false,
scales: {
r: {
beginAtZero: true,
max: 100,
ticks: { stepSize: 20 },
},
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context: any) {
const key = CRITERIA_ORDER[context.dataIndex]
const item = data.criteria_averages[key]
if (!item) return ""
return `${key}: ${item.avg}/${item.max} (${context.parsed.r}%)`
},
},
},
},
}
const criteriaBarChartData = computed(() => {
const labels = CRITERIA_ORDER.filter((k) => k in data.criteria_averages)
return {
labels,
datasets: [
{
label: "平均得分",
data: labels.map((k) => data.criteria_averages[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,
),
borderWidth: 2,
},
],
}
})
const barOptions = {
responsive: true,
aspectRatio: 1,
scales: {
y: { beginAtZero: true },
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context: any) {
const key = context.label
const item = data.criteria_averages[key]
if (!item) return ""
return `${item.avg} / ${item.max}`
},
},
},
},
}
const WORD_COLORS = [
"#2080f0",
"#18a058",
"#f0a020",
"#d03050",
"#722ed1",
"#13c2c2",
"#1890ff",
"#52c41a",
"#faad14",
"#f5222d",
]
function renderWordCloud() {
if (!wordcloudCanvas.value || data.word_frequencies.length === 0) return
if (wordcloudChart) {
wordcloudChart.destroy()
wordcloudChart = null
}
const words = data.word_frequencies
const maxCount = Math.max(...words.map((w) => w.count))
wordcloudChart = new ChartJS(wordcloudCanvas.value, {
type: "wordCloud" as any,
data: {
labels: words.map((w) => w.word),
datasets: [
{
label: "",
data: words.map((w) => 10 + (w.count / maxCount) * 50),
color: words.map((_, i) => WORD_COLORS[i % WORD_COLORS.length]),
rotate: 0,
} as any,
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context: any) {
const word = words[context.dataIndex]
return word ? `${word.word}: ${word.count}` : ""
},
},
},
},
},
})
}
const subOptions = computed<Duration>(() => {
const dur =
durationOptions.find((it) => it.value === query.duration) ??
durationOptions[0]
const x = dur.value!.toString().split(":")
return { [x[0]]: parseInt(x[1]) }
})
async function handleStatistics() {
const current = Date.now()
const end = formatISO(current)
const duration =
query.duration === "all"
? { end }
: { start: formatISO(sub(current, subOptions.value)), end }
const res = await getFlowchartStatistics(
duration,
query.problem,
query.username,
)
Object.assign(data, res.data)
await nextTick()
renderWordCloud()
}
onUnmounted(() => {
if (wordcloudChart) {
wordcloudChart.destroy()
}
})
</script>
<style scoped>
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.chart-container {
height: 280px;
position: relative;
}
.wordcloud-container {
height: 300px;
position: relative;
}
</style>

View File

@@ -0,0 +1,356 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { RouterLink } from "vue-router"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress"
import { useAuthModalStore } from "shared/store/authModal"
import { useScreenModeStore } from "shared/store/screenMode"
import { logout } from "../api"
import { useConfigStore } from "../store/config"
import { useUserStore } from "../store/user"
import { trickOrTreat } from "utils/functions"
const userStore = useUserStore()
const configStore = useConfigStore()
const authStore = useAuthModalStore()
const screenModeStore = useScreenModeStore()
const route = useRoute()
const router = useRouter()
const { isMobile, isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const isDark = useDark()
function toggleDark() {
const x = window.innerWidth / 2
const y = window.innerHeight / 2
const radius = Math.hypot(x, y)
if (!document.startViewTransition) {
isDark.value = !isDark.value
return
}
document
.startViewTransition(() => {
isDark.value = !isDark.value
})
.ready.then(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${radius}px at ${x}px ${y}px)`,
],
},
{
duration: 400,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
},
)
})
.catch(() => {})
}
// 从 store 中获取屏幕模式状态
const { screenMode } = storeToRefs(screenModeStore)
const names = [
"man-with-chinese-cap-1",
"cat-face",
"china",
"chicken",
"eyes",
"elephant",
"hear-no-evil-monkey",
"panda-face",
"penguin-1",
"rooster",
"star-struck-1",
"tomato",
"rocket",
"sparkles",
"money-bag",
"ghost",
"game-dice",
"ewe-1",
"artist-palette",
"baby-bottle",
]
function getRandomAvatar() {
const name = names[Math.floor(Math.random() * names.length)]
return `streamline-emojis:${name}`
}
const avatar = ref(getRandomAvatar())
const envVersion = computed(() => {
if (import.meta.env.PUBLIC_ENV === "test") {
return "测试版"
} else if (import.meta.env.PUBLIC_ENV === "dev") {
return "开发版"
}
return ""
})
const showEnvVersion = computed(() => {
return (
import.meta.env.PUBLIC_ENV === "test" ||
import.meta.env.PUBLIC_ENV === "dev"
)
})
const active = computed(() => {
const path = route.path.split("/")[1] || "problem"
return !["user", "setting"].includes(path) ? path : ""
})
async function handleLogout() {
await logout()
userStore.clearProfile()
router.replace("/")
}
function handleToggleDemoMode() {
const entering = !userStore.demoMode
userStore.toggleDemoMode()
// 进入演示模式时若正停在后台页面,当前界面已经失去权限,必须主动退出去
if (entering && route.path.startsWith("/admin")) {
router.push("/")
}
}
function renderIcon(icon: string) {
return () => h(Icon, { icon, width: 20 })
}
function learnLink(type: "python" | "c") {
return `/learn/${type}/${learnStep.value[type].toString().padStart(2, "0")}`
}
const menus = computed<MenuOption[]>(() => [
{
label: "自学",
key: "learn",
icon: renderIcon("fluent-emoji:books"),
children: [
{
label: () =>
h(
RouterLink,
{ to: learnLink("python") },
{ default: () => "Python" },
),
key: "learn-python",
},
{
label: () =>
h(RouterLink, { to: learnLink("c") }, { default: () => "C语言" }),
key: "learn-c",
},
],
},
{
label: () => h(RouterLink, { to: "/" }, { default: () => "题目" }),
key: "problem",
icon: renderIcon("fluent-emoji:memo"),
},
{
label: () =>
h(RouterLink, { to: "/problemset" }, { default: () => "题单" }),
key: "problemset",
icon: renderIcon("fluent-emoji:clipboard"),
},
{
label: () =>
h(RouterLink, { to: "/submission" }, { default: () => "提交" }),
key: "submission",
icon: renderIcon("fluent-emoji:inbox-tray"),
show: userStore.showSubmissions,
},
{
label: () => h(RouterLink, { to: "/contest" }, { default: () => "比赛" }),
key: "contest",
icon: renderIcon("fluent-emoji:chequered-flag"),
},
{
label: () => h(RouterLink, { to: "/rank" }, { default: () => "排名" }),
key: "rank",
icon: renderIcon("fluent-emoji:trophy"),
},
{
label: () => h(RouterLink, { to: "/class/pk" }, { default: () => "班级" }),
show: false,
key: "class",
icon: renderIcon("fluent-emoji:crossed-swords"),
},
{
label: () =>
h(RouterLink, { to: "/announcement" }, { default: () => "公告" }),
key: "announcement",
icon: renderIcon("fluent-emoji:loudspeaker"),
},
{
label: () =>
h(
RouterLink,
{ to: userStore.isSuperAdmin ? "/admin" : "/admin/problem/list" },
{ default: () => "后台" },
),
show: userStore.isAdminRole,
key: "admin",
icon: renderIcon("fluent-emoji:gear"),
},
])
const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
{
label: "我的主页",
key: "home",
icon: renderIcon("streamline-ultimate-color:newspaper-fold"),
props: {
onClick: () => router.push("/user"),
},
},
{
label: "我的消息",
key: "message",
show: false,
icon: renderIcon("streamline-emojis:herb"),
props: {
onClick: () => router.push("/message"),
},
},
{
label: "我的提交",
key: "status",
icon: renderIcon("streamline-ultimate-color:analytics-bars-3d"),
props: {
onClick: () => router.push("/submission?myself=1"),
},
},
{
label: "我的设置",
key: "setting",
icon: renderIcon("streamline-emojis:musical-score"),
props: {
onClick: () => router.push("/setting"),
},
},
{
label: "智能分析",
key: "ai-analysis",
icon: renderIcon("vscode-icons:file-type-gemini"),
props: {
onClick: () => router.push("/ai-analysis"),
},
},
{
label: userStore.demoMode ? "退出演示" : "进入演示",
key: "demo-mode",
show: userStore.canToggleDemoMode,
icon: renderIcon("fluent-emoji:graduation-cap"),
props: { onClick: handleToggleDemoMode },
},
{ type: "divider" },
{
label: "退出",
key: "logout",
icon: renderIcon("streamline-ultimate-color:coffee-cold"),
props: { onClick: handleLogout },
},
])
function goHome() {
router.push("/")
}
function handleMenuSelect(key: string) {
if (key === "dont-click") {
trickOrTreat()
}
}
</script>
<template>
<n-flex justify="space-between" align="center">
<n-flex align="center">
<n-flex align="center" class="title" @click="goHome">
<Icon icon="streamline-emojis:dog" :height="30"></Icon>
<div>{{ configStore.config?.website_name }}</div>
<div v-if="showEnvVersion">({{ envVersion }})</div>
</n-flex>
<div>
<n-menu
v-if="isDesktop"
mode="horizontal"
:options="menus"
:value="active"
@update:value="handleMenuSelect"
/>
</div>
</n-flex>
<n-flex align="center">
<n-dropdown
v-if="isMobile"
:options="menus"
size="large"
@select="handleMenuSelect"
>
<n-button>
<Icon icon="fluent-emoji:artist-palette" height="20"></Icon>
<span style="padding-left: 8px">菜单</span>
</n-button>
</n-dropdown>
<n-button
v-if="
isDesktop &&
(route.name === 'problem' || route.name === 'contest problem')
"
@click="() => screenModeStore.switchScreenMode()"
>
{{ screenMode }}
</n-button>
<div v-if="userStore.isFinished">
<n-dropdown v-if="userStore.isAuthed" :options="options" size="large">
<n-button>
<Icon :icon="avatar" height="20"></Icon>
<span style="padding-left: 8px">
{{ userStore.user!.username }}
</span>
</n-button>
</n-dropdown>
<n-flex align="center" v-else>
<n-button
secondary
type="primary"
@click="authStore.openLoginModal()"
>
登录
</n-button>
<n-button
tertiary
v-if="configStore.config?.allow_register"
@click="authStore.openSignupModal()"
>
注册
</n-button>
</n-flex>
</div>
<n-button :bordered="false" circle @click="toggleDark">
<template #icon>
<Icon v-if="isDark" icon="fluent-emoji:sun"></Icon>
<Icon v-else icon="fluent-emoji:full-moon"></Icon>
</template>
</n-button>
</n-flex>
</n-flex>
</template>
<style scoped>
.title {
font-size: 18px;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { getHitokoto } from "../api"
const hitokoto = reactive({
sentence: "",
from: "",
})
async function receive() {
try {
const res = await getHitokoto()
hitokoto.sentence = res.data.hitokoto
hitokoto.from = res.data.from
} catch (error) {
hitokoto.sentence = "获取一言失败,请点击重试"
hitokoto.from = "DEV"
}
}
onMounted(receive)
</script>
<template>
<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>
</template>
<style scoped>
.hitokoto {
cursor: pointer;
height: 36px;
min-width: 0;
display: flow-root;
overflow: hidden;
text-align: right;
line-height: 18px;
word-break: break-all;
}
.hitokoto::before {
content: "";
float: right;
width: 0;
height: 18px;
}
.hitokoto .sentence {
text-align: right;
}
.hitokoto .from {
float: right;
clear: right;
max-width: min(45%, 260px);
margin-left: 8px;
text-align: right;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
font-size: 12px;
line-height: 18px;
color: grey;
}
</style>

View File

@@ -0,0 +1,29 @@
<template>
<n-tooltip>
<template #trigger>
<n-button round :type="type ?? 'default'" @click="$emit('click')">
<template #icon>
<Icon :icon="icon" />
</template>
</n-button>
</template>
{{ tip }}
</n-tooltip>
</template>
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
defineProps<{
tip: string
icon: string
type?:
| "default"
| "tertiary"
| "primary"
| "info"
| "success"
| "warning"
| "error"
}>()
defineEmits(["click"])
</script>

View File

@@ -0,0 +1,201 @@
<script setup lang="ts">
import { getClassUsernames, login } from "../api"
import { storeToRefs } from "pinia"
import { useAuthModalStore } from "../store/authModal"
import { useConfigStore } from "../store/config"
import { useUserStore } from "../store/user"
import { useLoginSummaryStore } from "../store/loginSummary"
const userStore = useUserStore()
const configStore = useConfigStore()
const authStore = useAuthModalStore()
const loginSummaryStore = useLoginSummaryStore()
const {
loginModalOpen,
loginForm: form,
loginLoading: isLoading,
loginError: msg,
} = storeToRefs(authStore)
const loginRef = useTemplateRef("loginRef")
const classUserOptions = ref<SelectOption[]>([])
const classUserLoading = ref(false)
const isClassLogin = computed(() => Boolean(form.value.class))
const classList = computed<SelectOption[]>(() => {
const defaults = [{ label: "没有我所在的班级", value: "" }]
const configs =
configStore.config?.class_list.map((item) => ({
label: `${item.slice(0, 2)}计算机${item.slice(2)}`,
value: `ks${item}`,
})) ?? []
return [...defaults, ...configs]
})
const rules: FormRules = {
username: [
{ required: true, message: "用户名必填", trigger: ["blur", "change"] },
],
password: [
{ required: true, message: "密码必填", trigger: "blur" },
{ min: 6, max: 20, message: "长度在 6 到 20 位之间", trigger: "input" },
],
}
async function submit() {
loginRef.value!.validate(async (errors: FormRules | undefined) => {
if (!errors) {
try {
authStore.clearLoginError()
authStore.setLoginLoading(true)
const merged = {
username: form.value.username,
password: form.value.password,
}
if (form.value.class) {
merged.username = form.value.class + form.value.username
}
await login(merged)
} catch (err: any) {
if (err.data === "Your account has been disabled") {
authStore.setLoginError("此账号已被封禁")
} else if (err.data === "Invalid username or password") {
authStore.setLoginError("用户名或密码不正确")
} else {
authStore.setLoginError("无法登录")
}
} finally {
authStore.setLoginLoading(false)
}
if (!msg.value) {
authStore.closeLoginModal()
await userStore.getMyProfile()
loginSummaryStore.open()
}
}
})
}
function goSignup() {
authStore.switchToSignup()
}
async function loadClassUsernames(selectedClass: string) {
classUserLoading.value = true
try {
const res = await getClassUsernames(selectedClass)
classUserOptions.value = res.data.map((name: string) => ({
label: name,
value: name,
}))
} catch {
classUserOptions.value = []
} finally {
classUserLoading.value = false
}
}
watch(
() => form.value.class,
(selectedClass) => {
classUserOptions.value = []
form.value.username = ""
if (!selectedClass) {
classUserLoading.value = false
return
}
loadClassUsernames(selectedClass.slice(2))
},
)
onMounted(() => {
authStore.clearLoginError()
})
</script>
<template>
<n-modal
:mask-closable="false"
v-model:show="loginModalOpen"
preset="card"
title="登录"
style="width: 400px"
:auto-focus="false"
>
<n-form ref="loginRef" :model="form" :rules="rules" show-require-mark>
<n-alert :show-icon="false" class="tip">
关于选择班级的提醒<br />
1. 如果是上课统一生成的账号选择相应班级用户名直接写自己的名字
<br />
2.
同样是上课用的号但是没有你的班级选择没有我所在的班级用户名要写ks班级+姓名比如23计算机1班张三就写ks231张三
<br />
3. 如果是自己注册的号选择没有我所在的班级 <br />
</n-alert>
<n-form-item label="选择班级" path="class" :show-require-mark="false">
<n-select
v-model:value="form.class"
:options="classList"
clearable
name="class"
id="login-class"
/>
</n-form-item>
<n-form-item label="用户名" path="username">
<n-select
v-if="form.class"
v-model:value="form.username"
:options="classUserOptions"
:loading="classUserLoading"
clearable
filterable
:name="isClassLogin ? 'class-username' : 'username'"
:id="isClassLogin ? 'login-class-username' : 'login-username'"
placeholder="请选择姓名"
/>
<n-input
v-else
v-model:value="form.username"
autofocus
clearable
:name="isClassLogin ? 'class-username' : 'username'"
:id="isClassLogin ? 'login-class-username' : 'login-username'"
:autocomplete="isClassLogin ? 'off' : 'username'"
/>
</n-form-item>
<n-form-item label="密码" path="password">
<n-input
v-model:value="form.password"
clearable
type="password"
:name="isClassLogin ? 'class-password' : 'password'"
:id="isClassLogin ? 'login-class-password' : 'login-password'"
:autocomplete="isClassLogin ? 'new-password' : 'current-password'"
@keyup.enter="submit"
/>
</n-form-item>
<n-alert v-if="msg" type="error" :show-icon="false"> {{ msg }}</n-alert>
<n-form-item>
<n-flex style="width: 100%">
<n-button
type="primary"
:loading="isLoading"
@click="submit"
:style="{
flex: configStore.config?.allow_register ? '0 0 auto' : '1',
}"
>
登录
</n-button>
<n-button v-if="configStore.config?.allow_register" @click="goSignup">
没有账号立即注册
</n-button>
</n-flex>
</n-form-item>
</n-form>
</n-modal>
</template>
<style scoped>
.tip {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useLoginSummaryStore } from "shared/store/loginSummary"
import { parseTime } from "utils/functions"
const loginSummaryStore = useLoginSummaryStore()
const { isDesktop } = useBreakpoints()
const lastLoginTime = computed(() => {
const summary = loginSummaryStore.summary
if (!summary?.start) {
return ""
}
return parseTime(summary.start, "YYYY-MM-DD HH:mm")
})
const hasAnalysis = computed(() => !!loginSummaryStore.analysis)
</script>
<template>
<n-modal
v-model:show="loginSummaryStore.show"
preset="card"
title="登录速报"
style="width: min(760px, 92vw)"
>
<n-spin :show="loginSummaryStore.loading" size="small">
<n-flex vertical size="large">
<n-text v-if="lastLoginTime">上次登录时间{{ lastLoginTime }}</n-text>
<n-grid :cols="isDesktop ? 3 : 1" :x-gap="16" :y-gap="16">
<n-gi>
<n-statistic
label="新增题目"
:value="loginSummaryStore.summary?.new_problem_count ?? 0"
/>
</n-gi>
<n-gi>
<n-statistic
label="提交次数"
:value="loginSummaryStore.summary?.submission_count ?? 0"
/>
</n-gi>
<n-gi>
<n-statistic
label="AC 次数"
:value="loginSummaryStore.summary?.accepted_count ?? 0"
/>
</n-gi>
<n-gi>
<n-statistic
label="AC 题目数"
:value="loginSummaryStore.summary?.solved_count ?? 0"
/>
</n-gi>
<n-gi>
<n-statistic
label="流程图提交"
:value="
loginSummaryStore.summary?.flowchart_submission_count ?? 0
"
/>
</n-gi>
</n-grid>
<n-divider>AI 分析</n-divider>
<n-alert
v-if="loginSummaryStore.analysisError"
type="warning"
:show-icon="false"
>
{{ loginSummaryStore.analysisError }}
</n-alert>
<MdPreview
v-if="hasAnalysis"
:model-value="loginSummaryStore.analysis"
/>
<n-empty v-else description="期间提交数少于 3 次,暂不生成 AI 分析" />
</n-flex>
</n-spin>
</n-modal>
</template>

View File

@@ -0,0 +1,40 @@
<template>
<MdEditor
:theme="isDark ? 'dark' : 'light'"
v-model="modelValue"
@onUploadImg="onUploadImg"
/>
</template>
<script setup lang="ts">
import { MdEditor } from "md-editor-v3"
import "md-editor-v3/lib/style.css"
import { uploadImage } from "../../admin/api"
const isDark = useDark()
const modelValue = defineModel<string>("value")
const message = useMessage()
const onUploadImg = async (
files: File[],
callback: (urls: string[]) => void,
) => {
try {
const res = await Promise.all(
files.map(async (file) => {
const path = await uploadImage(file)
if (!path) {
message.error("图片上传失败")
return ""
}
return path
}),
)
callback(res.filter((url) => url !== ""))
} catch (err) {
message.error("图片上传失败")
callback([])
}
}
</script>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { copyToClipboard } from "utils/functions"
import { useMermaid } from "shared/composables/useMermaid"
const modelValue = defineModel<string>({ default: "" })
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
const { renderFlowchart, renderError, renderSuccess } = useMermaid()
const emit = defineEmits<{
renderSuccess: []
}>()
const renderMermaid = async () => {
await renderFlowchart(mermaidContainer.value, modelValue.value)
if (renderSuccess.value) emit("renderSuccess")
}
onMounted(() => {
nextTick(renderMermaid)
})
watch(modelValue, renderMermaid)
const clearCode = () => {
modelValue.value = ""
}
const copyCode = () => {
copyToClipboard(modelValue.value)
}
onBeforeUnmount(() => {
if (mermaidContainer.value) {
mermaidContainer.value.innerHTML = ""
}
})
</script>
<template>
<n-flex>
<n-flex vertical>
<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-flex>
</n-flex>
<n-input
class="code-editor"
v-model:value="modelValue"
type="textarea"
:autosize="{ minRows: 10, maxRows: 20 }"
/>
</n-flex>
<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-flex>
<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>
</n-flex>
</n-flex>
</template>
<style scoped>
.code-editor {
flex: 1;
width: 400px;
}
.mermaid-container {
width: 400px;
min-height: 400px;
border: 1px solid #d9d9d9;
border-radius: 3px;
padding: 16px;
}
</style>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { useBreakpoints } from "shared/composables/breakpoints"
interface Props {
total: number
limit: number
page: number
}
const {
total,
limit: initialLimit = 10,
page: initialPage = 1,
} = defineProps<Props>()
const emit = defineEmits(["update:limit", "update:page"])
const { isDesktop } = useBreakpoints()
const limit = ref(initialLimit)
const page = ref(initialPage)
const sizes = [10, 30, 50]
// 必须 emit 数值emit ref 对象时,父组件用普通 ref 接会拿不到数字,
// 而且每次 emit 的都是同一个对象,父组件那边察觉不到变化
watch(limit, (value) => emit("update:limit", value))
watch(page, (value) => emit("update:page", value))
// 父组件改页码 / 每页条数(比如换搜索条件后重置到第一页)时同步回来
watch(
() => initialLimit,
(value) => (limit.value = value),
)
watch(
() => initialPage,
(value) => (page.value = value),
)
</script>
<template>
<n-pagination
v-if="total"
class="right margin"
:item-count="total"
v-model:page="page"
v-model:page-size="limit"
:page-sizes="sizes"
:page-slot="isDesktop ? 7 : 5"
show-size-picker
/>
</template>
<style scoped>
.margin {
margin: 20px 0;
}
.right {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { signup } from "../api"
import { storeToRefs } from "pinia"
import { useAuthModalStore } from "../store/authModal"
const authStore = useAuthModalStore()
const {
signupModalOpen,
signupForm: form,
signupLoading: isLoading,
signupError: msg,
} = storeToRefs(authStore)
const signupRef = useTemplateRef("signupRef")
const rules: FormRules = {
username: [{ required: true, message: "用户名必填", trigger: "blur" }],
email: [{ required: true, message: "邮箱必填", trigger: "blur" }],
password: [
{ required: true, message: "密码必填", trigger: "blur" },
{ min: 6, max: 20, message: "长度在 6 到 20 位之间", trigger: "input" },
],
passwordAgain: [
{ required: true, message: "密码必填", trigger: "blur" },
{ min: 6, max: 20, message: "长度在 6 到 20 位之间", trigger: "input" },
{
validator: (_: FormItemRule, value: string) =>
value === form.value.password,
message: "两次密码输入不一致",
trigger: "blur",
},
],
}
function goLogin() {
authStore.switchToLogin()
}
function submit() {
signupRef.value!.validate(async (errors: FormRules | undefined) => {
if (!errors) {
try {
authStore.clearSignupError()
authStore.setSignupLoading(true)
await signup({
username: form.value.username,
email: form.value.email,
password: form.value.password,
})
} catch (err: any) {
if (err.data === "Username already exists") {
authStore.setSignupError("用户名已存在")
} else if (err.data === "Email already exists") {
authStore.setSignupError("邮箱已存在")
} else {
authStore.setSignupError("无法注册")
}
} finally {
authStore.setSignupLoading(false)
}
if (!msg.value) {
authStore.closeSignupModal()
}
}
})
}
</script>
<template>
<n-modal
:mask-closable="false"
v-model:show="signupModalOpen"
preset="card"
title="注册"
style="width: 400px"
:auto-focus="false"
>
<n-form ref="signupRef" :model="form" :rules="rules" show-require-mark>
<n-form-item label="用户名" path="username">
<n-input
v-model:value="form.username"
autofocus
clearable
name="username"
id="signup-username"
autocomplete="username"
/>
</n-form-item>
<n-form-item label="邮箱" path="email">
<n-input
v-model:value="form.email"
clearable
name="email"
id="signup-email"
autocomplete="email"
@change="submit"
/>
</n-form-item>
<n-form-item label="密码" path="password">
<n-input
v-model:value="form.password"
clearable
type="password"
name="password"
id="signup-password"
autocomplete="new-password"
/>
</n-form-item>
<n-form-item label="确认密码" path="passwordAgain">
<n-input
v-model:value="form.passwordAgain"
clearable
type="password"
name="passwordAgain"
id="signup-password-again"
autocomplete="new-password"
/>
</n-form-item>
<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 @click="goLogin">已经注册现在登录</n-button>
</n-space>
</n-form-item>
</n-form>
</n-modal>
</template>

View File

@@ -0,0 +1,470 @@
<template>
<n-flex align="center">
<n-input
placeholder="用户(可选)"
v-model:value="query.username"
style="width: 150px"
clearable
/>
<n-input
placeholder="题号(可选)"
v-model:value="query.problem"
style="width: 120px"
clearable
/>
<n-select
style="width: 120px"
v-model:value="query.duration"
:options="options"
/>
<n-button type="primary" @click="handleStatistics">统计</n-button>
<n-button v-if="route.name !== 'submissions'" @click="goSubmissions">
前往提交列表
</n-button>
</n-flex>
<n-empty
v-if="count.total === 0"
description="暂无数据"
style="margin: 40px 0"
/>
<template v-if="count.total > 0">
<n-divider style="margin: 16px 0" />
<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>
</div>
<div class="stat-item">
<n-text>正确提交</n-text>
<n-gradient-text type="primary" font-size="28">{{
count.accepted
}}</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>
</div>
<template v-if="person.count > 0">
<div class="stat-item">
<n-text>完成人数</n-text>
<n-gradient-text type="error" font-size="28">{{
list.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>
</div>
<div class="stat-item">
<n-text>完成度</n-text>
<n-gradient-text type="success" font-size="28">{{
adjustedPersonRate
}}</n-gradient-text>
</div>
</template>
</n-flex>
<n-divider style="margin: 16px 0" />
<n-tabs animated type="line">
<n-tab-pane name="charts" tab="数据图表">
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 12px">
<n-gi>
<n-card title="提交正确率">
<Doughnut :data="pieChartData" :options="pieChartOptions" />
</n-card>
</n-gi>
<n-gi v-if="person.count > 0">
<n-card title="班级完成度">
<Doughnut
:data="completionChartData"
:options="completionChartOptions"
/>
</n-card>
</n-gi>
</n-grid>
</n-tab-pane>
<n-tab-pane name="submissions" tab="提交记录">
<n-data-table
v-if="list.length"
striped
:columns="columns"
:data="list"
:row-key="rowKey"
:expanded-row-keys="expandedRowKeys"
@update:expanded-row-keys="updateExpandedRowKeys"
:row-props="rowProps"
style="margin-top: 12px"
/>
</n-tab-pane>
<n-tab-pane
name="unaccepted"
:tab="`未完成(${visibleUnaccepted.length}`"
>
<n-flex align="center" style="margin: 12px 0">
<n-switch v-model:value="hideMode" size="large">
<template #checked>请假隐藏中</template>
<template #unchecked>请假隐藏</template>
</n-switch>
<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>
<template v-for="item in visibleUnaccepted" :key="item.username">
<n-tag
v-if="hideMode"
closable
size="large"
style="font-size: 20px"
@close="hideStudent(item.username)"
>
{{ item.real_name }}
</n-tag>
<span v-else style="font-size: 24px">{{ item.real_name }}</span>
</template>
</n-flex>
</n-tab-pane>
</n-tabs>
</template>
</template>
<script setup lang="ts">
import { h } from "vue"
import { formatISO, sub, type Duration } from "date-fns"
import { getSubmissionStatistics } from "oj/api"
import { DURATION_OPTIONS } from "utils/constants"
import { Doughnut } from "vue-chartjs"
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
import { NButton, NFlex, NText, type DataTableRowKey } from "naive-ui"
import { JUDGE_STATUS } from "utils/constants"
import type { SUBMISSION_RESULT } from "utils/types"
// 注册 Chart.js 组件
ChartJS.register(ArcElement, Title, Tooltip, Legend)
interface Props {
problem: string
username: string
}
const props = defineProps<Props>()
const options: SelectOption[] = [
{ label: "10分钟内", value: "minutes:10" },
{ label: "20分钟内", value: "minutes:20" },
{ label: "30分钟内", value: "minutes:30" },
...DURATION_OPTIONS,
{ label: "全部时段", value: "all" },
]
function openSubmission(id: string) {
window.open(`/submission/${id}`, "_blank", "noopener")
}
const columns: DataTableColumn<UserStatistic>[] = [
{
type: "expand",
renderExpand: (row) => {
return h(NFlex, { size: "small", wrap: true }, () =>
row.submission_items.map((item) =>
h(
NButton,
{
size: "small",
tertiary: true,
type: JUDGE_STATUS[item.result]?.type ?? "default",
style: "width: 120px",
onClick: (event: MouseEvent) => {
event.stopPropagation()
openSubmission(item.id)
},
},
() => item.id.toString().slice(0, 12),
),
),
)
},
},
{ title: "用户", key: "username" },
{ title: "提交数", key: "submission_count" },
{ title: "已解决", key: "accepted_count" },
{ title: "正确率", key: "correct_rate" },
]
const query = reactive({
username: props.username,
problem: props.problem,
duration: options[0].value,
})
const count = reactive({
total: 0,
accepted: 0,
rate: 0,
})
const person = reactive({
count: 0,
rate: 0,
})
const route = useRoute()
const router = useRouter()
interface UserStatistic {
username: string
submission_count: number
accepted_count: number
correct_rate: string
submission_items: Array<{
id: string
result: SUBMISSION_RESULT
}>
}
interface UnacceptedItem {
username: string
real_name: string
}
const list = ref<UserStatistic[]>([])
const listUnaccepted = ref<UnacceptedItem[]>([])
const expandedRowKeys = ref<DataTableRowKey[]>([])
const HIDE_DURATION = 2 * 60 * 60 * 1000
const STORAGE_KEY = "oj_hidden_students"
function loadHidden(): Record<string, number> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")
} catch {
return {}
}
}
const hiddenStudents = ref<Record<string, number>>(loadHidden())
const hideMode = ref(false)
function saveHidden(data: Record<string, number>) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
}
function hideStudent(username: string) {
hiddenStudents.value = {
...hiddenStudents.value,
[username]: Date.now() + HIDE_DURATION,
}
saveHidden(hiddenStudents.value)
}
function showAll() {
hiddenStudents.value = {}
saveHidden({})
}
const visibleUnaccepted = computed(() => {
const now = Date.now()
return listUnaccepted.value.filter((item) => {
const exp = hiddenStudents.value[item.username]
return !exp || exp <= now
})
})
const hiddenCount = computed(() => {
const now = Date.now()
return listUnaccepted.value.filter((item) => {
const exp = hiddenStudents.value[item.username]
return !!exp && exp > now
}).length
})
const adjustedPersonCount = computed(() => person.count - hiddenCount.value)
const adjustedPersonRate = computed(() => {
if (adjustedPersonCount.value <= 0) return "0%"
const rate = Math.min(
100,
(list.value.length / adjustedPersonCount.value) * 100,
)
return `${Math.round(rate * 100) / 100}%`
})
onMounted(() => {
const now = Date.now()
const cleaned = Object.fromEntries(
Object.entries(hiddenStudents.value).filter(([, exp]) => exp > now),
)
hiddenStudents.value = cleaned
saveHidden(cleaned)
})
// 饼图数据 - 提交正确率分布
const pieChartData = computed(() => {
const wrongCount = count.total - count.accepted
return {
labels: ["正确提交", "错误提交"],
datasets: [
{
label: "提交数",
data: [count.accepted, wrongCount],
backgroundColor: ["rgba(75, 192, 192, 0.6)", "rgba(255, 99, 132, 0.6)"],
borderColor: ["rgba(75, 192, 192, 1)", "rgba(255, 99, 132, 1)"],
borderWidth: 2,
},
],
}
})
const pieChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom" as const,
},
tooltip: {
callbacks: {
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 percentage = ((value / total) * 100).toFixed(1)
return `${label}: ${value} (${percentage}%)`
},
},
},
},
}
// 环形图数据 - 班级完成度
const completionChartData = computed(() => {
const completedCount = list.value.length
const uncompletedCount = Math.max(
0,
adjustedPersonCount.value - completedCount,
)
return {
labels: ["已完成", "未完成"],
datasets: [
{
label: "人数",
data: [completedCount, uncompletedCount],
backgroundColor: ["rgba(106, 176, 76, 0.6)", "rgba(255, 159, 64, 0.6)"],
borderColor: ["rgba(106, 176, 76, 1)", "rgba(255, 159, 64, 1)"],
borderWidth: 2,
},
],
}
})
const completionChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom" as const,
},
tooltip: {
callbacks: {
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 percentage = ((value / total) * 100).toFixed(1)
return `${label}: ${value} (${percentage}%)`
},
},
},
},
}
const subOptions = computed<Duration>(() => {
let dur = options.find((it) => it.value === query.duration) ?? options[0]
const x = dur.value!.toString().split(":")
const unit = x[0]
const n = x[1]
return { [unit]: parseInt(n) }
})
function goSubmissions() {
router.push({
name: "submissions",
query: {
username: query.username,
problem: query.problem,
},
})
}
async function handleStatistics() {
const current = Date.now()
const end = formatISO(current)
const duration =
query.duration === "all"
? { end }
: { start: formatISO(sub(current, subOptions.value)), end }
const res = await getSubmissionStatistics(
duration,
query.problem,
query.username,
)
count.total = res.data.submission_count
count.accepted = res.data.accepted_count
count.rate = res.data.correct_rate
list.value = res.data.data
listUnaccepted.value = res.data.data_unaccepted
person.count = res.data.person_count
person.rate = res.data.person_rate
}
function rowKey(row: UserStatistic): DataTableRowKey {
return row.username
}
function updateExpandedRowKeys(keys: DataTableRowKey[]) {
expandedRowKeys.value = keys.slice(-1)
}
function rowProps(row: UserStatistic) {
return {
style: "cursor: pointer;",
onClick: () => {
const key = rowKey(row)
const isExpanded = expandedRowKeys.value.includes(key)
expandedRowKeys.value = isExpanded ? [] : [key]
},
}
}
</script>
<style scoped>
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { JUDGE_STATUS } from "utils/constants"
import type { SUBMISSION_RESULT } from "utils/types"
interface Props {
result: SUBMISSION_RESULT
}
defineProps<Props>()
</script>
<template>
<n-tag :type="JUDGE_STATUS[result]['type']">
{{ JUDGE_STATUS[result]["name"] }}
</n-tag>
</template>
<style scoped></style>

View File

@@ -0,0 +1,153 @@
<script lang="ts" setup>
import { cpp } from "@codemirror/lang-cpp"
import { python } from "@codemirror/lang-python"
import { sql, SQLite } from "@codemirror/lang-sql"
import { bracketMatching } from "@codemirror/language"
import { EditorView } from "@codemirror/view"
import { Codemirror } from "vue-codemirror"
import {
autocompletion,
closeBrackets,
completeAnyWord,
} from "@codemirror/autocomplete"
import type { Extension } from "@codemirror/state"
import type { LANGUAGE } from "utils/types"
import { oneDark } from "../themes/oneDark"
import { smoothy } from "../themes/smoothy"
import { styleTheme } from "shared/extensions/baseTheme"
import { useCodeSync, SYNC_ERROR_CODES } from "../composables/sync"
import { useBreakpoints } from "../composables/breakpoints"
import { enhanceCompletion } from "shared/extensions/autocompletion"
const isDark = useDark()
interface EditorReadyPayload {
view: EditorView
state: any
container: HTMLElement
}
interface Props {
sync: boolean
problem: string
language?: LANGUAGE
fontSize?: number
height?: string
readonly?: boolean
placeholder?: string
}
const {
sync,
problem,
language = "Python3",
fontSize = 20,
height = "100%",
readonly = false,
placeholder = "",
} = defineProps<Props>()
const code = defineModel<string>("value")
const emit = defineEmits<{
syncClosed: []
syncStatusChange: [
status: { otherUser?: { name: string; isSuperAdmin: boolean } },
]
}>()
const { isDesktop } = useBreakpoints()
const langExtension = computed((): Extension => {
if (language === "SQL")
return sql({ dialect: SQLite, upperCaseKeywords: true })
return ["Python2", "Python3"].includes(language) ? python() : cpp()
})
const extensions = computed(() => [
styleTheme,
langExtension.value,
bracketMatching(),
closeBrackets(),
isDark.value ? oneDark : smoothy,
autocompletion({
override: [enhanceCompletion(language), completeAnyWord],
}),
getInitialExtension(),
])
const { startSync, stopSync, getInitialExtension } = useCodeSync()
const editorView = ref<EditorView | null>(null)
let cleanupSync: (() => void) | null = null
const cleanupSyncResources = () => {
if (cleanupSync) {
cleanupSync()
cleanupSync = null
}
stopSync()
}
const initSync = async () => {
if (!editorView.value || !problem || !isDesktop.value) return
cleanupSyncResources()
cleanupSync = await startSync({
problemId: problem,
editorView: editorView.value as EditorView,
onStatusChange: (status) => {
// 处理需要断开同步的情况
if (
(status.errorCode === SYNC_ERROR_CODES.SUPER_ADMIN_LEFT ||
status.errorCode === SYNC_ERROR_CODES.MISSING_SUPER_ADMIN) &&
!status.connected
) {
emit("syncClosed")
}
emit("syncStatusChange", { otherUser: status.otherUser })
},
})
}
const handleEditorReady = (payload: EditorReadyPayload) => {
editorView.value = payload.view as EditorView
if (sync) {
initSync()
}
}
watch(
() => sync,
(shouldSync) => {
if (shouldSync) {
initSync()
} else {
cleanupSyncResources()
}
},
)
watch(
() => problem,
(newProblem, oldProblem) => {
if (newProblem !== oldProblem && sync) {
initSync()
}
},
)
onUnmounted(cleanupSyncResources)
</script>
<template>
<Codemirror
v-model="code"
indentWithTab
:extensions="extensions"
:disabled="readonly"
:tab-size="4"
:placeholder="placeholder"
:style="{ height, fontSize: `${fontSize}px` }"
@ready="handleEditorReady"
/>
</template>

View File

@@ -0,0 +1,33 @@
<template>
<n-tooltip>
<template #trigger>
<n-button text @click="handleClick">
<slot />
</n-button>
</template>
点击复制
</n-tooltip>
</template>
<script lang="ts" setup>
import { copyToClipboard } from "utils/functions"
const message = useMessage()
const slots = useSlots()
async function handleClick() {
const textToCopy = getTextFromSlot()
const success = await copyToClipboard(textToCopy)
if (success) {
message.success("已复制")
} else {
message.error("复制失败")
}
}
function getTextFromSlot() {
const vnodes = slots.default?.()
if (!vnodes) return ""
return vnodes.map((vnode) => vnode.children).join("")
}
</script>

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
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"
interface Props {
title: string
simple?: boolean
minHeight?: number
}
const rawHtml = defineModel<string>("value")
type InsertFnType = (url: string, alt: string, href: string) => void
const { title, minHeight = 0, simple = false } = defineProps<Props>()
const message = useMessage()
const editorRef = shallowRef<IDomEditor>()
const toolbarEditorRef = shallowRef<IDomEditor>()
const toolbarConfig: Partial<IToolbarConfig> = {
toolbarKeys: [
"blockquote",
"headerSelect",
"fontSize",
"lineHeight",
"|",
"bold",
"underline",
"italic",
"through",
"color",
"bgColor",
"|",
"bulletedList",
"numberedList",
"justifyLeft",
"justifyCenter",
"justifyRight",
"|",
"uploadImage",
"emotion",
"insertLink",
"insertTable",
"divider",
"|",
"clearStyle",
"undo",
"redo",
],
}
const toolbarConfigSimple: Partial<IToolbarConfig> = {
toolbarKeys: [
"bold",
"color",
"bgColor",
"emotion",
"uploadImage",
"insertLink",
"clearStyle",
"undo",
"redo",
],
}
const editorConfig: Partial<IEditorConfig> = {
scroll: false,
MENU_CONF: {
// @ts-ignore
uploadImage: { customUpload },
},
}
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor) editor.destroy()
})
function onClick() {
if (!editorRef.value) return
editorRef.value.blur()
editorRef.value.focus()
}
async function handleCreated(editor: IDomEditor) {
editorRef.value = editor
await nextTick()
toolbarEditorRef.value = editor
}
async function customUpload(file: File, insertFn: InsertFnType) {
const path = await uploadImage(file)
if (!path) {
message.error("图片上传失败")
return
}
const url = path
const alt = "图片"
const href = ""
insertFn(url, alt, href)
}
</script>
<template>
<div class="title" v-if="title">{{ title }}</div>
<div class="editorWrapper">
<Toolbar
class="toolbar"
:editor="toolbarEditorRef"
:defaultConfig="simple ? toolbarConfigSimple : toolbarConfig"
mode="simple"
/>
<Editor
@click="onClick"
:style="{ minHeight: minHeight + 'px' }"
v-model="rawHtml"
:defaultConfig="editorConfig"
mode="simple"
@onCreated="handleCreated"
/>
</div>
</template>
<style scoped>
.title {
margin-bottom: 12px;
}
.toolbar {
border-bottom: 1px solid #ddd;
}
.editorWrapper {
border: 1px solid #ddd;
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<n-popover trigger="hover" placement="top">
<template #trigger>
<div class="badge-container">
<img
:src="badge.badge.icon"
:alt="badge.badge.name"
class="badge-icon"
@error="handleImageError"
/>
</div>
</template>
<n-card size="small" class="badge-popover">
<n-flex vertical>
<n-text strong>{{ badge.badge.name }}</n-text>
<n-tag type="info"> 获得条件{{ getConditionText() }} </n-tag>
<n-text depth="3">
获得时间{{ parseTime(badge.earned_time, "YYYY-MM-DD HH:mm:ss") }}
</n-text>
</n-flex>
</n-card>
</n-popover>
</template>
<script setup lang="ts">
import type { UserBadge } from "utils/types"
import { parseTime } from "utils/functions"
interface Props {
badge: UserBadge
}
const props = defineProps<Props>()
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
// 如果图片加载失败,显示默认图标
img.src = "/badge-1.png" // 使用默认徽章图标
}
function getConditionText() {
const { condition_type, condition_value } = props.badge.badge
switch (condition_type) {
case "all_problems":
return "完成所有题目"
case "problem_count":
return `完成 ${condition_value} 道题目`
case "score":
return `获得 ${condition_value}`
default:
return "未知条件"
}
}
</script>
<style scoped>
.badge-container {
position: relative;
display: inline-block;
cursor: pointer;
width: 44px;
height: 44px;
}
.badge-icon {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #e0e0e0;
transition: all 0.3s ease;
}
.badge-icon:hover {
transform: scale(1.1);
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
.badge-popover {
max-width: 280px;
}
.badge-popover .n-space {
gap: 6px;
}
</style>