feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码
This commit is contained in:
34
apps/web/src/shared/api.ts
Normal file
34
apps/web/src/shared/api.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import http from "utils/http"
|
||||
import type { Profile, Tag } from "utils/types"
|
||||
|
||||
export function login(data: { username: string; password: string }) {
|
||||
return http.post("login", data)
|
||||
}
|
||||
|
||||
export function signup(data: {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
}) {
|
||||
return http.post("register", data)
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return http.get("logout")
|
||||
}
|
||||
|
||||
export function getProfile(username: string = "") {
|
||||
return http.get<Profile>("profile", { params: { username } })
|
||||
}
|
||||
|
||||
export function getProblemTagList() {
|
||||
return http.get<Tag[]>("problem/tags")
|
||||
}
|
||||
|
||||
export function getHitokoto() {
|
||||
return http.get("hitokoto")
|
||||
}
|
||||
|
||||
export function getClassUsernames(classroom: string) {
|
||||
return http.get("class_usernames", { params: { classroom: classroom } })
|
||||
}
|
||||
50
apps/web/src/shared/components/AchievementIcon.vue
Normal file
50
apps/web/src/shared/components/AchievementIcon.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
|
||||
// 成就图标统一走 iconify(noto 彩色 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>
|
||||
101
apps/web/src/shared/components/AchievementToast.vue
Normal file
101
apps/web/src/shared/components/AchievementToast.vue
Normal 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>
|
||||
35
apps/web/src/shared/components/AuthorSelect.vue
Normal file
35
apps/web/src/shared/components/AuthorSelect.vue
Normal 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>
|
||||
69
apps/web/src/shared/components/Beian.vue
Normal file
69
apps/web/src/shared/components/Beian.vue
Normal 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>
|
||||
65
apps/web/src/shared/components/CodeEditor.vue
Normal file
65
apps/web/src/shared/components/CodeEditor.vue
Normal 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>
|
||||
18
apps/web/src/shared/components/ContestTitle.vue
Normal file
18
apps/web/src/shared/components/ContestTitle.vue
Normal 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>
|
||||
21
apps/web/src/shared/components/ContestType.vue
Normal file
21
apps/web/src/shared/components/ContestType.vue
Normal 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>
|
||||
35
apps/web/src/shared/components/Copy.vue
Normal file
35
apps/web/src/shared/components/Copy.vue
Normal 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>
|
||||
275
apps/web/src/shared/components/FlowchartEditor/CustomNode.vue
Normal file
275
apps/web/src/shared/components/FlowchartEditor/CustomNode.vue
Normal 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>
|
||||
@@ -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>
|
||||
238
apps/web/src/shared/components/FlowchartEditor/NodeHandles.vue
Normal file
238
apps/web/src/shared/components/FlowchartEditor/NodeHandles.vue
Normal 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>
|
||||
413
apps/web/src/shared/components/FlowchartEditor/Toolbar.vue
Normal file
413
apps/web/src/shared/components/FlowchartEditor/Toolbar.vue
Normal 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>
|
||||
325
apps/web/src/shared/components/FlowchartEditor/index.vue
Normal file
325
apps/web/src/shared/components/FlowchartEditor/index.vue
Normal 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>
|
||||
89
apps/web/src/shared/components/FlowchartEditor/useCache.ts
Normal file
89
apps/web/src/shared/components/FlowchartEditor/useCache.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
84
apps/web/src/shared/components/FlowchartEditor/useDnD.ts
Normal file
84
apps/web/src/shared/components/FlowchartEditor/useDnD.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
72
apps/web/src/shared/components/FlowchartEditor/useHistory.ts
Normal file
72
apps/web/src/shared/components/FlowchartEditor/useHistory.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
137
apps/web/src/shared/components/FlowchartEditor/useNodeStyles.ts
Normal file
137
apps/web/src/shared/components/FlowchartEditor/useNodeStyles.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
577
apps/web/src/shared/components/FlowchartStatisticsPanel.vue
Normal file
577
apps/web/src/shared/components/FlowchartStatisticsPanel.vue
Normal 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>
|
||||
356
apps/web/src/shared/components/Header.vue
Normal file
356
apps/web/src/shared/components/Header.vue
Normal 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>
|
||||
69
apps/web/src/shared/components/Hitokoto.vue
Normal file
69
apps/web/src/shared/components/Hitokoto.vue
Normal 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>
|
||||
29
apps/web/src/shared/components/IconButton.vue
Normal file
29
apps/web/src/shared/components/IconButton.vue
Normal 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>
|
||||
201
apps/web/src/shared/components/Login.vue
Normal file
201
apps/web/src/shared/components/Login.vue
Normal 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>
|
||||
83
apps/web/src/shared/components/LoginSummaryModal.vue
Normal file
83
apps/web/src/shared/components/LoginSummaryModal.vue
Normal 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>
|
||||
40
apps/web/src/shared/components/MarkdownEditor.vue
Normal file
40
apps/web/src/shared/components/MarkdownEditor.vue
Normal 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>
|
||||
93
apps/web/src/shared/components/MermaidEditor.vue
Normal file
93
apps/web/src/shared/components/MermaidEditor.vue
Normal 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>
|
||||
61
apps/web/src/shared/components/Pagination.vue
Normal file
61
apps/web/src/shared/components/Pagination.vue
Normal 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>
|
||||
131
apps/web/src/shared/components/Signup.vue
Normal file
131
apps/web/src/shared/components/Signup.vue
Normal 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>
|
||||
470
apps/web/src/shared/components/StatisticsPanel.vue
Normal file
470
apps/web/src/shared/components/StatisticsPanel.vue
Normal 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>
|
||||
17
apps/web/src/shared/components/SubmissionResultTag.vue
Normal file
17
apps/web/src/shared/components/SubmissionResultTag.vue
Normal 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>
|
||||
153
apps/web/src/shared/components/SyncCodeEditor.vue
Normal file
153
apps/web/src/shared/components/SyncCodeEditor.vue
Normal 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>
|
||||
33
apps/web/src/shared/components/TextCopy.vue
Normal file
33
apps/web/src/shared/components/TextCopy.vue
Normal 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>
|
||||
144
apps/web/src/shared/components/TextEditor.vue
Normal file
144
apps/web/src/shared/components/TextEditor.vue
Normal 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>
|
||||
87
apps/web/src/shared/components/UserBadge.vue
Normal file
87
apps/web/src/shared/components/UserBadge.vue
Normal 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>
|
||||
20
apps/web/src/shared/composables/breakpoints.ts
Normal file
20
apps/web/src/shared/composables/breakpoints.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
breakpointsTailwind,
|
||||
useBreakpoints as useVueUseBreakpoints,
|
||||
} from "@vueuse/core"
|
||||
|
||||
/**
|
||||
* 响应式断点检测 composable
|
||||
* 每次调用创建新的断点检测实例
|
||||
*/
|
||||
export function useBreakpoints() {
|
||||
const breakpoints = useVueUseBreakpoints(breakpointsTailwind)
|
||||
|
||||
const isMobile = breakpoints.smallerOrEqual("md")
|
||||
const isDesktop = breakpoints.greater("md")
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
isDesktop,
|
||||
}
|
||||
}
|
||||
41
apps/web/src/shared/composables/configUpdate.ts
Normal file
41
apps/web/src/shared/composables/configUpdate.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import {
|
||||
useConfigWebSocket,
|
||||
type ConfigUpdate,
|
||||
} from "shared/composables/websocket"
|
||||
|
||||
export function useConfigUpdate() {
|
||||
const configStore = useConfigStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 处理 WebSocket 配置更新
|
||||
const handleConfigUpdate = (data: ConfigUpdate) => {
|
||||
// 更新全局配置 - 使用响应式方式
|
||||
if (data.key in configStore.config) {
|
||||
// 直接修改 ref 的值来触发响应式更新
|
||||
;(configStore.config as any)[data.key] = data.value
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 WebSocket - handler 会在 onMounted 时自动添加
|
||||
const { connect, disconnect } = useConfigWebSocket(handleConfigUpdate)
|
||||
|
||||
// 监听登录状态变化
|
||||
watch(
|
||||
() => userStore.isAuthed,
|
||||
(isAuthed) => {
|
||||
if (isAuthed) {
|
||||
connect()
|
||||
} else {
|
||||
disconnect()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
}
|
||||
}
|
||||
15
apps/web/src/shared/composables/learnProgress.ts
Normal file
15
apps/web/src/shared/composables/learnProgress.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useStorage } from "@vueuse/core"
|
||||
import { STORAGE_KEY } from "utils/constants"
|
||||
|
||||
export type TutorialType = "python" | "c"
|
||||
|
||||
// 模块级单例:学习页写入、导航栏读取,必须共用同一个响应式引用,
|
||||
// 否则导航栏的链接不会随学习进度更新
|
||||
const learnStep = useStorage<Record<TutorialType, number>>(
|
||||
STORAGE_KEY.LEARN_CURRENT_STEP,
|
||||
{ python: 1, c: 1 },
|
||||
)
|
||||
|
||||
export function useLearnProgress() {
|
||||
return { learnStep }
|
||||
}
|
||||
132
apps/web/src/shared/composables/maxkb.ts
Normal file
132
apps/web/src/shared/composables/maxkb.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import {
|
||||
useConfigWebSocket,
|
||||
type ConfigUpdate,
|
||||
} from "shared/composables/websocket"
|
||||
|
||||
export function useMaxKB() {
|
||||
const configStore = useConfigStore()
|
||||
const userStore = useUserStore()
|
||||
const isLoaded = ref(false)
|
||||
|
||||
// 处理 WebSocket 配置更新 - 只处理 MaxKB 相关
|
||||
const handleConfigUpdate = (data: ConfigUpdate) => {
|
||||
if (data.key === "enable_maxkb") {
|
||||
if (data.value) {
|
||||
loadMaxKBScript()
|
||||
} else {
|
||||
removeMaxKBScript()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 WebSocket
|
||||
const { connect, disconnect } = useConfigWebSocket(handleConfigUpdate)
|
||||
|
||||
// 监听登录状态变化
|
||||
watch(
|
||||
() => userStore.isAuthed,
|
||||
(isAuthed) => {
|
||||
if (isAuthed) {
|
||||
connect()
|
||||
} else {
|
||||
disconnect()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const loadMaxKBScript = () => {
|
||||
const { enable_maxkb } = configStore.config
|
||||
|
||||
if (!enable_maxkb) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector(
|
||||
`script[src="${import.meta.env.PUBLIC_MAXKB_URL}"]`,
|
||||
)
|
||||
if (existingScript) {
|
||||
isLoaded.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 创建并插入脚本标签
|
||||
const script = document.createElement("script")
|
||||
script.src = import.meta.env.PUBLIC_MAXKB_URL
|
||||
script.async = true
|
||||
script.defer = true
|
||||
|
||||
script.onload = () => {
|
||||
isLoaded.value = true
|
||||
}
|
||||
|
||||
script.onerror = () => {
|
||||
console.error("Failed to load MaxKB script")
|
||||
}
|
||||
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
const removeMaxKBScript = () => {
|
||||
// 把 script 也删除
|
||||
const script = document.querySelector(
|
||||
`script[src="${import.meta.env.PUBLIC_MAXKB_URL}"]`,
|
||||
)
|
||||
if (script) {
|
||||
script.remove()
|
||||
}
|
||||
// 等待DOM加载完成后删除所有id以"maxkb-"开头的元素
|
||||
const removeMaxKBElements = () => {
|
||||
// 查找所有id以"maxkb-"开头的元素
|
||||
const elements = document.querySelectorAll('[id^="maxkb-"]')
|
||||
|
||||
elements.forEach((element) => {
|
||||
element.remove()
|
||||
})
|
||||
}
|
||||
|
||||
// 如果DOM已经加载完成,直接执行删除
|
||||
if (document.readyState === "complete") {
|
||||
removeMaxKBElements()
|
||||
} else {
|
||||
// 等待DOM加载完成
|
||||
window.addEventListener("load", removeMaxKBElements, { once: true })
|
||||
}
|
||||
|
||||
// 移除MaxKB脚本标签
|
||||
const existingScript = document.querySelector(
|
||||
`script[src="${import.meta.env.PUBLIC_MAXKB_URL}"]`,
|
||||
)
|
||||
if (existingScript) {
|
||||
existingScript.remove()
|
||||
}
|
||||
|
||||
// 重置加载状态
|
||||
isLoaded.value = false
|
||||
}
|
||||
|
||||
// 连接 WebSocket
|
||||
onMounted(() => {
|
||||
connect()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => configStore.config.enable_maxkb,
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
loadMaxKBScript()
|
||||
} else {
|
||||
removeMaxKBScript()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
loadMaxKBScript,
|
||||
removeMaxKBScript,
|
||||
isLoaded: readonly(isLoaded),
|
||||
}
|
||||
}
|
||||
154
apps/web/src/shared/composables/pagination.ts
Normal file
154
apps/web/src/shared/composables/pagination.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { reactive, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { filterEmptyValue } from "utils/functions"
|
||||
|
||||
export interface PaginationQuery {
|
||||
page: number
|
||||
limit: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface UsePaginationOptions {
|
||||
/** 默认每页条数 */
|
||||
defaultLimit?: number
|
||||
/** 默认页码 */
|
||||
defaultPage?: number
|
||||
/** 当其他查询条件变化时是否重置页码 */
|
||||
resetPageOnChange?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页相关的 composable,处理分页状态和 URL 同步
|
||||
* 每次调用创建新的分页状态实例
|
||||
* @param initialQuery 初始查询参数对象
|
||||
* @param options 配置选项
|
||||
*/
|
||||
export function usePagination<T extends Record<string, any>>(
|
||||
initialQuery: Omit<T, "page" | "limit"> = {} as Omit<T, "page" | "limit">,
|
||||
options: UsePaginationOptions = {},
|
||||
) {
|
||||
const {
|
||||
defaultLimit = 10,
|
||||
defaultPage = 1,
|
||||
resetPageOnChange = true,
|
||||
} = options
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 从 URL 查询参数初始化状态
|
||||
const query = reactive({
|
||||
page: parseInt(<string>route.query.page) || defaultPage,
|
||||
limit: parseInt(<string>route.query.limit) || defaultLimit,
|
||||
...initialQuery,
|
||||
}) as unknown as T & PaginationQuery
|
||||
|
||||
// 同步 URL 查询参数到本地状态
|
||||
function syncFromRoute() {
|
||||
;(query as any).page = parseInt(<string>route.query.page) || defaultPage
|
||||
;(query as any).limit = parseInt(<string>route.query.limit) || defaultLimit
|
||||
|
||||
// 同步其他查询参数
|
||||
Object.keys(initialQuery).forEach((key) => {
|
||||
const value = route.query[key]
|
||||
if (value !== undefined) {
|
||||
// 处理不同类型的参数
|
||||
if (typeof initialQuery[key] === "boolean") {
|
||||
;(query as any)[key] = value === "1" || value === "true"
|
||||
} else if (typeof initialQuery[key] === "number") {
|
||||
;(query as any)[key] = parseInt(<string>value) || initialQuery[key]
|
||||
} else {
|
||||
;(query as any)[key] = <string>value || initialQuery[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 更新 URL
|
||||
function updateRoute() {
|
||||
const newQuery = filterEmptyValue(query)
|
||||
router.push({
|
||||
path: route.path,
|
||||
query: newQuery,
|
||||
})
|
||||
}
|
||||
|
||||
// 重置页码到第一页
|
||||
function resetPage() {
|
||||
;(query as any).page = defaultPage
|
||||
}
|
||||
|
||||
// 清空所有查询条件(除了分页参数)
|
||||
function clearQuery() {
|
||||
Object.keys(initialQuery).forEach((key) => {
|
||||
const initialValue = initialQuery[key]
|
||||
if (typeof initialValue === "string") {
|
||||
;(query as any)[key] = ""
|
||||
} else if (typeof initialValue === "boolean") {
|
||||
;(query as any)[key] = false
|
||||
} else if (typeof initialValue === "number") {
|
||||
;(query as any)[key] = 0
|
||||
} else {
|
||||
;(query as any)[key] = initialValue
|
||||
}
|
||||
})
|
||||
resetPage()
|
||||
}
|
||||
|
||||
// 监听页码变化,同步到 URL
|
||||
watch(() => query.page, updateRoute)
|
||||
|
||||
// 监听每页条数变化,重置页码并同步到 URL
|
||||
watch(
|
||||
() => query.limit,
|
||||
() => {
|
||||
if (resetPageOnChange) {
|
||||
resetPage()
|
||||
}
|
||||
updateRoute()
|
||||
},
|
||||
)
|
||||
|
||||
// 监听其他查询条件变化,重置页码并同步到 URL
|
||||
if (resetPageOnChange && Object.keys(initialQuery).length > 0) {
|
||||
const otherQueryKeys = Object.keys(initialQuery)
|
||||
watch(
|
||||
() => otherQueryKeys.map((key) => query[key]),
|
||||
() => {
|
||||
resetPage()
|
||||
updateRoute()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
}
|
||||
|
||||
// 监听路由变化,同步到本地状态
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
syncFromRoute()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
return {
|
||||
query,
|
||||
updateRoute,
|
||||
resetPage,
|
||||
clearQuery,
|
||||
syncFromRoute,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化版本的分页 composable,只处理基本的分页逻辑
|
||||
* 每次调用创建新的分页状态实例
|
||||
* @param defaultLimit 默认每页条数
|
||||
* @param defaultPage 默认页码
|
||||
*/
|
||||
export function useSimplePagination(defaultLimit = 10, defaultPage = 1) {
|
||||
return usePagination(
|
||||
{},
|
||||
{ defaultLimit, defaultPage, resetPageOnChange: false },
|
||||
)
|
||||
}
|
||||
15
apps/web/src/shared/composables/rarity.ts
Normal file
15
apps/web/src/shared/composables/rarity.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useDark } from "@vueuse/core"
|
||||
import { computed } from "vue"
|
||||
import { RARITY_TEXT_COLOR } from "utils/constants"
|
||||
|
||||
/**
|
||||
* 成就稀有度的文字配色,跟随明暗主题切换。
|
||||
* 两套色值都压到 4.5:1 以上,不然浅色模式下白金和黄金的小字看不清。
|
||||
* 边框和色块不用这个,直接用 RARITY_COLOR 的原色。
|
||||
*/
|
||||
export function useRarityColor() {
|
||||
const isDark = useDark()
|
||||
return computed(() =>
|
||||
isDark.value ? RARITY_TEXT_COLOR.dark : RARITY_TEXT_COLOR.light,
|
||||
)
|
||||
}
|
||||
412
apps/web/src/shared/composables/sync.ts
Normal file
412
apps/web/src/shared/composables/sync.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { useMessage } from "naive-ui"
|
||||
import { useUserStore } from "../store/user"
|
||||
import type { EditorView } from "@codemirror/view"
|
||||
import { Compartment } from "@codemirror/state"
|
||||
import type { WebrtcProvider } from "y-webrtc"
|
||||
import type { Doc, Text } from "yjs"
|
||||
|
||||
// 常量定义
|
||||
const SYNC_CONSTANTS = {
|
||||
MAX_ROOM_USERS: 2,
|
||||
AWARENESS_SYNC_DELAY: 500,
|
||||
INIT_SYNC_TIMEOUT: 500,
|
||||
SUPER_ADMIN_COLOR: "#ff6b6b",
|
||||
REGULAR_USER_COLOR: "#4dabf7",
|
||||
} as const
|
||||
|
||||
// 错误类型码
|
||||
export const SYNC_ERROR_CODES = {
|
||||
SUPER_ADMIN_LEFT: "SUPER_ADMIN_LEFT",
|
||||
MISSING_SUPER_ADMIN: "MISSING_SUPER_ADMIN",
|
||||
} as const
|
||||
|
||||
// 界面和通知文案
|
||||
export const SYNC_MESSAGES = {
|
||||
// 超管离开
|
||||
SUPER_ADMIN_LEFT: (name: string) => `超管 ${name} 已离开`,
|
||||
|
||||
// 缺少超管
|
||||
MISSING_SUPER_ADMIN: "协同编辑需要超管",
|
||||
|
||||
// 连接成功
|
||||
SYNC_ACTIVE: "协同编辑已激活!",
|
||||
|
||||
// 连接断开
|
||||
CONNECTION_LOST: "协同编辑已断开",
|
||||
|
||||
// 等待相关
|
||||
WAITING_STUDENT: "正在等待学生加入...",
|
||||
WAITING_ADMIN: "正在等待超管加入...",
|
||||
|
||||
// Form.vue 界面文案
|
||||
SYNC_ON: "断开同步",
|
||||
SYNC_OFF: "开启同步",
|
||||
SYNCING_WITH: (name: string) => `与 ${name} 同步中`,
|
||||
STUDENT_LEFT: (name?: string) => (name ? `${name}已离开` : "可以关闭同步"),
|
||||
} as const
|
||||
|
||||
// 类型定义
|
||||
type SyncState = "waiting" | "active" | "error"
|
||||
type SyncErrorCode = (typeof SYNC_ERROR_CODES)[keyof typeof SYNC_ERROR_CODES]
|
||||
|
||||
interface UserInfo {
|
||||
name: string
|
||||
isSuperAdmin: boolean
|
||||
}
|
||||
|
||||
interface PeersEvent {
|
||||
webrtcPeers: string[]
|
||||
}
|
||||
|
||||
interface StatusEvent {
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
interface SyncedEvent {
|
||||
synced: boolean
|
||||
}
|
||||
|
||||
interface SyncOptions {
|
||||
problemId: string
|
||||
editorView: EditorView
|
||||
onStatusChange?: (status: SyncStatus) => void
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
connected: boolean
|
||||
roomUsers: number
|
||||
canSync: boolean
|
||||
message: string
|
||||
error?: string
|
||||
errorCode?: SyncErrorCode
|
||||
otherUser?: UserInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码同步 composable
|
||||
* 每次调用创建新的同步实例
|
||||
*/
|
||||
export function useCodeSync() {
|
||||
const userStore = useUserStore()
|
||||
const message = useMessage()
|
||||
|
||||
// 每次调用创建新的实例变量
|
||||
let ydoc: Doc | null = null
|
||||
let provider: WebrtcProvider | null = null
|
||||
let ytext: Text | null = null
|
||||
const collabCompartment = new Compartment()
|
||||
let currentEditorView: EditorView | null = null
|
||||
let lastSyncState: SyncState | null = null
|
||||
let roomUserInfo = new Map<number, UserInfo>()
|
||||
let hasShownSuperAdminLeftMessage = false
|
||||
|
||||
const updateStatus = (
|
||||
status: SyncStatus,
|
||||
onStatusChange?: (status: SyncStatus) => void,
|
||||
) => {
|
||||
onStatusChange?.(status)
|
||||
}
|
||||
|
||||
const normalizeClientId = (clientId: number | string): number => {
|
||||
return typeof clientId === "string" ? parseInt(clientId, 10) : clientId
|
||||
}
|
||||
|
||||
const checkHasSuperAdmin = (awarenessStates: Map<number, any>): boolean => {
|
||||
if (userStore.isSuperAdmin) return true
|
||||
return Array.from(awarenessStates.values()).some(
|
||||
(state) => state.user?.isSuperAdmin,
|
||||
)
|
||||
}
|
||||
|
||||
const getOtherUserInfo = (
|
||||
awarenessStates: Map<number, any>,
|
||||
): UserInfo | undefined => {
|
||||
if (!provider) return undefined
|
||||
|
||||
const localClientId = provider.awareness.clientID
|
||||
for (const [clientId, state] of awarenessStates) {
|
||||
if (clientId !== localClientId && state.user) {
|
||||
return {
|
||||
name: state.user.name,
|
||||
isSuperAdmin: state.user.isSuperAdmin,
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const checkIfSuperAdminLeft = (
|
||||
removedClientIds: number[],
|
||||
onStatusChange?: (status: SyncStatus) => void,
|
||||
) => {
|
||||
if (userStore.isSuperAdmin || hasShownSuperAdminLeftMessage) return
|
||||
|
||||
const superAdminInfo = removedClientIds
|
||||
.map((id) => roomUserInfo.get(id))
|
||||
.find((info) => info?.isSuperAdmin)
|
||||
|
||||
if (superAdminInfo) {
|
||||
hasShownSuperAdminLeftMessage = true
|
||||
const leftMessage = SYNC_MESSAGES.SUPER_ADMIN_LEFT(superAdminInfo.name)
|
||||
updateStatus(
|
||||
{
|
||||
connected: false,
|
||||
roomUsers: 0,
|
||||
canSync: false,
|
||||
message: leftMessage,
|
||||
error: leftMessage,
|
||||
errorCode: SYNC_ERROR_CODES.SUPER_ADMIN_LEFT,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
message.warning(leftMessage)
|
||||
stopSync()
|
||||
}
|
||||
}
|
||||
|
||||
const checkRoomPermissions = (
|
||||
roomUsers: number,
|
||||
onStatusChange?: (status: SyncStatus) => void,
|
||||
) => {
|
||||
const awarenessStates = provider?.awareness.getStates()
|
||||
if (!awarenessStates) return
|
||||
|
||||
const hasSuperAdmin = checkHasSuperAdmin(awarenessStates)
|
||||
const canSync = roomUsers === SYNC_CONSTANTS.MAX_ROOM_USERS && hasSuperAdmin
|
||||
const otherUser = getOtherUserInfo(awarenessStates)
|
||||
|
||||
if (roomUsers === SYNC_CONSTANTS.MAX_ROOM_USERS && !hasSuperAdmin) {
|
||||
if (lastSyncState === "error") return
|
||||
|
||||
updateStatus(
|
||||
{
|
||||
connected: false,
|
||||
roomUsers,
|
||||
canSync: false,
|
||||
message: SYNC_MESSAGES.MISSING_SUPER_ADMIN,
|
||||
error: SYNC_MESSAGES.MISSING_SUPER_ADMIN,
|
||||
errorCode: SYNC_ERROR_CODES.MISSING_SUPER_ADMIN,
|
||||
otherUser,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
message.error(SYNC_MESSAGES.MISSING_SUPER_ADMIN)
|
||||
lastSyncState = "error"
|
||||
stopSync()
|
||||
return
|
||||
} else if (canSync) {
|
||||
updateStatus(
|
||||
{
|
||||
connected: true,
|
||||
roomUsers,
|
||||
canSync: true,
|
||||
message: SYNC_MESSAGES.SYNC_ACTIVE,
|
||||
otherUser,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
if (lastSyncState !== "active") {
|
||||
message.success(SYNC_MESSAGES.SYNC_ACTIVE)
|
||||
lastSyncState = "active"
|
||||
}
|
||||
} else {
|
||||
updateStatus(
|
||||
{
|
||||
connected: true,
|
||||
roomUsers,
|
||||
canSync: false,
|
||||
message:
|
||||
roomUsers === 1
|
||||
? SYNC_MESSAGES.WAITING_STUDENT
|
||||
: SYNC_MESSAGES.WAITING_ADMIN,
|
||||
otherUser,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
lastSyncState = "waiting"
|
||||
}
|
||||
}
|
||||
|
||||
const setupContentSync = (
|
||||
ytext: Text,
|
||||
provider: WebrtcProvider,
|
||||
savedContent: string,
|
||||
) => {
|
||||
let hasInitialized = false
|
||||
|
||||
const initTimeout = setTimeout(() => {
|
||||
if (!hasInitialized && ytext.length === 0 && savedContent) {
|
||||
ytext.insert(0, savedContent)
|
||||
}
|
||||
hasInitialized = true
|
||||
}, SYNC_CONSTANTS.INIT_SYNC_TIMEOUT)
|
||||
|
||||
provider.on("synced", (event: SyncedEvent) => {
|
||||
if (!event.synced || hasInitialized) return
|
||||
|
||||
clearTimeout(initTimeout)
|
||||
if (ytext.length === 0 && savedContent) {
|
||||
ytext.insert(0, savedContent)
|
||||
}
|
||||
hasInitialized = true
|
||||
})
|
||||
}
|
||||
|
||||
async function startSync(options: SyncOptions): Promise<() => void> {
|
||||
const { problemId, editorView, onStatusChange } = options
|
||||
|
||||
if (!userStore.isAuthed) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
// 动态导入 yjs 相关模块
|
||||
const [Y, { WebrtcProvider }, { yCollab }] = await Promise.all([
|
||||
import("yjs"),
|
||||
import("y-webrtc"),
|
||||
import("y-codemirror.next"),
|
||||
])
|
||||
|
||||
// 初始化文档和提供者
|
||||
ydoc = new Y.Doc()
|
||||
ytext = ydoc.getText("codemirror")
|
||||
const roomName = `problem-${problemId}`
|
||||
|
||||
provider = new WebrtcProvider(roomName, ydoc, {
|
||||
signaling: [import.meta.env.PUBLIC_SIGNALING_URL],
|
||||
maxConns: 1,
|
||||
filterBcConns: true,
|
||||
})
|
||||
|
||||
// 监听连接状态
|
||||
provider.on("status", (event: StatusEvent) => {
|
||||
if (!event.connected) {
|
||||
updateStatus(
|
||||
{
|
||||
connected: false,
|
||||
roomUsers: 0,
|
||||
canSync: false,
|
||||
message: SYNC_MESSAGES.CONNECTION_LOST,
|
||||
error: SYNC_MESSAGES.CONNECTION_LOST,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
message.warning(SYNC_MESSAGES.CONNECTION_LOST)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听用户加入/离开
|
||||
provider.on("peers", (event: PeersEvent) => {
|
||||
const roomUsers = event.webrtcPeers.length + 1
|
||||
setTimeout(() => {
|
||||
checkRoomPermissions(roomUsers, onStatusChange)
|
||||
}, SYNC_CONSTANTS.AWARENESS_SYNC_DELAY)
|
||||
})
|
||||
|
||||
// 监听 awareness 变化
|
||||
provider.awareness.on("change", (changes: any) => {
|
||||
if (!provider) return
|
||||
|
||||
const awarenessStates = provider.awareness.getStates()
|
||||
|
||||
if (changes.removed?.length > 0) {
|
||||
checkIfSuperAdminLeft(changes.removed, onStatusChange)
|
||||
}
|
||||
|
||||
awarenessStates.forEach((state, clientId) => {
|
||||
if (state.user) {
|
||||
const normalizedId = normalizeClientId(clientId)
|
||||
roomUserInfo.set(normalizedId, {
|
||||
name: state.user.name,
|
||||
isSuperAdmin: state.user.isSuperAdmin,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
checkRoomPermissions(awarenessStates.size, onStatusChange)
|
||||
})
|
||||
|
||||
// 配置编辑器扩展
|
||||
if (editorView && ytext) {
|
||||
currentEditorView = editorView
|
||||
const userColor = userStore.isSuperAdmin
|
||||
? SYNC_CONSTANTS.SUPER_ADMIN_COLOR
|
||||
: SYNC_CONSTANTS.REGULAR_USER_COLOR
|
||||
const userName = userStore.user?.username || "匿名用户"
|
||||
const savedContent = editorView.state.doc.toString()
|
||||
|
||||
// 设置用户信息
|
||||
provider.awareness.setLocalStateField("user", {
|
||||
name: userName,
|
||||
color: userColor,
|
||||
isSuperAdmin: userStore.isSuperAdmin,
|
||||
})
|
||||
|
||||
// 清空编辑器并应用协同扩展
|
||||
editorView.dispatch({
|
||||
changes: { from: 0, to: editorView.state.doc.length, insert: "" },
|
||||
})
|
||||
|
||||
const collabExt = yCollab(ytext, provider.awareness)
|
||||
editorView.dispatch({
|
||||
effects: collabCompartment.reconfigure(collabExt),
|
||||
})
|
||||
|
||||
// 设置内容同步
|
||||
setupContentSync(ytext, provider, savedContent)
|
||||
|
||||
// 设置初始状态
|
||||
const waitingMessage = userStore.isSuperAdmin
|
||||
? SYNC_MESSAGES.WAITING_STUDENT
|
||||
: SYNC_MESSAGES.WAITING_ADMIN
|
||||
|
||||
updateStatus(
|
||||
{
|
||||
connected: true,
|
||||
roomUsers: 1,
|
||||
canSync: false,
|
||||
message: waitingMessage,
|
||||
},
|
||||
onStatusChange,
|
||||
)
|
||||
|
||||
message.info(waitingMessage)
|
||||
lastSyncState = "waiting"
|
||||
}
|
||||
|
||||
return () => stopSync()
|
||||
}
|
||||
|
||||
function stopSync() {
|
||||
if (currentEditorView) {
|
||||
try {
|
||||
currentEditorView.dispatch({
|
||||
effects: collabCompartment.reconfigure([]),
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("移除协同编辑扩展失败:", error)
|
||||
}
|
||||
currentEditorView = null
|
||||
}
|
||||
|
||||
provider?.disconnect()
|
||||
provider?.destroy()
|
||||
ydoc?.destroy()
|
||||
|
||||
provider = null
|
||||
ydoc = null
|
||||
ytext = null
|
||||
lastSyncState = null
|
||||
roomUserInfo.clear()
|
||||
hasShownSuperAdminLeftMessage = false
|
||||
}
|
||||
|
||||
function getInitialExtension() {
|
||||
return collabCompartment.of([])
|
||||
}
|
||||
|
||||
return {
|
||||
startSync,
|
||||
stopSync,
|
||||
getInitialExtension,
|
||||
}
|
||||
}
|
||||
334
apps/web/src/shared/composables/useMermaid.ts
Normal file
334
apps/web/src/shared/composables/useMermaid.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { getRandomId } from "utils/functions"
|
||||
|
||||
const mermaidThemeVariables = {
|
||||
primaryColor: "#e0f2fe",
|
||||
primaryTextColor: "#0f172a",
|
||||
primaryBorderColor: "#0284c7",
|
||||
lineColor: "#64748b",
|
||||
arrowheadColor: "#64748b",
|
||||
secondaryColor: "#f5f3ff",
|
||||
tertiaryColor: "#ecfdf5",
|
||||
background: "#ffffff",
|
||||
mainBkg: "#f8fafc",
|
||||
secondBkg: "#eef2ff",
|
||||
tertiaryBkg: "#f0fdfa",
|
||||
nodeBorder: "#2563eb",
|
||||
clusterBkg: "#f8fafc",
|
||||
clusterBorder: "#cbd5e1",
|
||||
edgeLabelBackground: "#ffffff",
|
||||
fontFamily:
|
||||
'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
}
|
||||
|
||||
const semanticNodeClasses = [
|
||||
"startNode",
|
||||
"endNode",
|
||||
"startEnd",
|
||||
"input",
|
||||
"output",
|
||||
"process",
|
||||
"decision",
|
||||
"loop",
|
||||
]
|
||||
|
||||
const displayStyleId = "oj-mermaid-display-style"
|
||||
|
||||
const mermaidDisplayStyle = `
|
||||
.oj-mermaid-flowchart {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node rect,
|
||||
.oj-mermaid-flowchart g.node polygon,
|
||||
.oj-mermaid-flowchart g.node ellipse,
|
||||
.oj-mermaid-flowchart g.node circle,
|
||||
.oj-mermaid-flowchart g.node path {
|
||||
stroke-width: 2px !important;
|
||||
filter: drop-shadow(0 6px 12px rgba(15, 23, 42, 0.12));
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.startNode rect,
|
||||
.oj-mermaid-flowchart g.node.startNode polygon,
|
||||
.oj-mermaid-flowchart g.node.startNode ellipse,
|
||||
.oj-mermaid-flowchart g.node.startNode circle,
|
||||
.oj-mermaid-flowchart g.node.startNode path,
|
||||
.oj-mermaid-flowchart g.node.startEnd rect,
|
||||
.oj-mermaid-flowchart g.node.startEnd polygon,
|
||||
.oj-mermaid-flowchart g.node.startEnd ellipse,
|
||||
.oj-mermaid-flowchart g.node.startEnd circle,
|
||||
.oj-mermaid-flowchart g.node.startEnd path {
|
||||
fill: #dcfce7 !important;
|
||||
stroke: #16a34a !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.endNode rect,
|
||||
.oj-mermaid-flowchart g.node.endNode polygon,
|
||||
.oj-mermaid-flowchart g.node.endNode ellipse,
|
||||
.oj-mermaid-flowchart g.node.endNode circle,
|
||||
.oj-mermaid-flowchart g.node.endNode path {
|
||||
fill: #fee2e2 !important;
|
||||
stroke: #dc2626 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.input rect,
|
||||
.oj-mermaid-flowchart g.node.input polygon,
|
||||
.oj-mermaid-flowchart g.node.input ellipse,
|
||||
.oj-mermaid-flowchart g.node.input circle,
|
||||
.oj-mermaid-flowchart g.node.input path {
|
||||
fill: #dbeafe !important;
|
||||
stroke: #2563eb !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.output rect,
|
||||
.oj-mermaid-flowchart g.node.output polygon,
|
||||
.oj-mermaid-flowchart g.node.output ellipse,
|
||||
.oj-mermaid-flowchart g.node.output circle,
|
||||
.oj-mermaid-flowchart g.node.output path {
|
||||
fill: #ede9fe !important;
|
||||
stroke: #7c3aed !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.process rect,
|
||||
.oj-mermaid-flowchart g.node.process polygon,
|
||||
.oj-mermaid-flowchart g.node.process ellipse,
|
||||
.oj-mermaid-flowchart g.node.process circle,
|
||||
.oj-mermaid-flowchart g.node.process path {
|
||||
fill: #f0f9ff !important;
|
||||
stroke: #0284c7 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.decision rect,
|
||||
.oj-mermaid-flowchart g.node.decision polygon,
|
||||
.oj-mermaid-flowchart g.node.decision ellipse,
|
||||
.oj-mermaid-flowchart g.node.decision circle,
|
||||
.oj-mermaid-flowchart g.node.decision path {
|
||||
fill: #fef3c7 !important;
|
||||
stroke: #d97706 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.loop rect,
|
||||
.oj-mermaid-flowchart g.node.loop polygon,
|
||||
.oj-mermaid-flowchart g.node.loop ellipse,
|
||||
.oj-mermaid-flowchart g.node.loop circle,
|
||||
.oj-mermaid-flowchart g.node.loop path {
|
||||
fill: #fae8ff !important;
|
||||
stroke: #c026d3 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-0 rect,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-0 polygon,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-0 ellipse,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-0 circle,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-0 path {
|
||||
fill: #dbeafe !important;
|
||||
stroke: #2563eb !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-1 rect,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-1 polygon,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-1 ellipse,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-1 circle,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-1 path {
|
||||
fill: #ccfbf1 !important;
|
||||
stroke: #0d9488 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-2 rect,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-2 polygon,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-2 ellipse,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-2 circle,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-2 path {
|
||||
fill: #ede9fe !important;
|
||||
stroke: #7c3aed !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-3 rect,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-3 polygon,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-3 ellipse,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-3 circle,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-3 path {
|
||||
fill: #ffe4e6 !important;
|
||||
stroke: #e11d48 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-4 rect,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-4 polygon,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-4 ellipse,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-4 circle,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-4 path {
|
||||
fill: #fef3c7 !important;
|
||||
stroke: #d97706 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-5 rect,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-5 polygon,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-5 ellipse,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-5 circle,
|
||||
.oj-mermaid-flowchart g.node.oj-node-palette-5 path {
|
||||
fill: #dcfce7 !important;
|
||||
stroke: #16a34a !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart g.node .label,
|
||||
.oj-mermaid-flowchart g.node .nodeLabel,
|
||||
.oj-mermaid-flowchart g.node .nodeLabel p,
|
||||
.oj-mermaid-flowchart g.node .label span {
|
||||
color: #0f172a !important;
|
||||
fill: #0f172a !important;
|
||||
font-weight: 650 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart .edgePaths path.path,
|
||||
.oj-mermaid-flowchart .flowchart-link {
|
||||
stroke: #64748b !important;
|
||||
stroke-width: 2.4px !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart marker path,
|
||||
.oj-mermaid-flowchart .marker {
|
||||
fill: #64748b !important;
|
||||
stroke: #64748b !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart .edgeLabel rect,
|
||||
.oj-mermaid-flowchart .edgeLabel .labelBkg {
|
||||
fill: rgba(255, 255, 255, 0.94) !important;
|
||||
stroke: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
.oj-mermaid-flowchart .edgeLabel,
|
||||
.oj-mermaid-flowchart .edgeLabel span,
|
||||
.oj-mermaid-flowchart .edgeLabel p {
|
||||
color: #334155 !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
`
|
||||
|
||||
const svgNamespace = "http://www.w3.org/2000/svg"
|
||||
|
||||
function getNodeLabel(node: SVGGElement): string {
|
||||
const el =
|
||||
node.querySelector(".nodeLabel p") ||
|
||||
node.querySelector(".nodeLabel") ||
|
||||
node.querySelector(".label span") ||
|
||||
node.querySelector(".label")
|
||||
return el?.textContent?.trim() ?? ""
|
||||
}
|
||||
|
||||
function applyFlowchartDisplayStyle(container: HTMLElement) {
|
||||
container.classList.add("oj-mermaid-surface")
|
||||
|
||||
const svg = container.querySelector("svg")
|
||||
if (!svg) return
|
||||
|
||||
svg.classList.add("oj-mermaid-flowchart")
|
||||
|
||||
const nodes = Array.from(svg.querySelectorAll<SVGGElement>("g.node"))
|
||||
|
||||
// Assign palette indices by label so same label → same color, different labels → different colors
|
||||
const labelPaletteMap = new Map<string, number>()
|
||||
let paletteCounter = 0
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const hasSemanticClass = semanticNodeClasses.some((className) =>
|
||||
node.classList.contains(className),
|
||||
)
|
||||
if (!hasSemanticClass) {
|
||||
const label = getNodeLabel(node)
|
||||
if (!labelPaletteMap.has(label)) {
|
||||
labelPaletteMap.set(label, paletteCounter % 6)
|
||||
paletteCounter++
|
||||
}
|
||||
node.classList.add(`oj-node-palette-${labelPaletteMap.get(label)}`)
|
||||
}
|
||||
})
|
||||
|
||||
svg.querySelector(`#${displayStyleId}`)?.remove()
|
||||
const style = document.createElementNS(svgNamespace, "style")
|
||||
style.setAttribute("id", displayStyleId)
|
||||
style.textContent = mermaidDisplayStyle
|
||||
svg.insertBefore(style, svg.firstChild)
|
||||
}
|
||||
|
||||
function getChromeVersion(): number {
|
||||
const match = navigator.userAgent.match(/Chrome\/(\d+)/)
|
||||
return match ? parseInt(match[1]) : Infinity
|
||||
}
|
||||
|
||||
let mermaidInstance: any = null
|
||||
let mermaidIsLegacy = false
|
||||
|
||||
async function loadMermaid() {
|
||||
if (!mermaidInstance) {
|
||||
if (getChromeVersion() < 94) {
|
||||
mermaidInstance = (await import("mermaid-legacy")).default
|
||||
mermaidIsLegacy = true
|
||||
} else {
|
||||
mermaidInstance = (await import("mermaid")).default
|
||||
}
|
||||
mermaidInstance.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
theme: "base",
|
||||
themeVariables: mermaidThemeVariables,
|
||||
})
|
||||
}
|
||||
return mermaidInstance
|
||||
}
|
||||
|
||||
export function useMermaid() {
|
||||
const renderError = ref<string | null>(null)
|
||||
const renderSuccess = ref(false)
|
||||
let renderGeneration = 0
|
||||
|
||||
const renderFlowchart = async (
|
||||
container: HTMLElement | null,
|
||||
mermaidCode: string,
|
||||
) => {
|
||||
renderError.value = null
|
||||
renderSuccess.value = false
|
||||
|
||||
if (container) container.innerHTML = ""
|
||||
|
||||
if (!container || !mermaidCode?.trim()) return
|
||||
|
||||
const gen = ++renderGeneration
|
||||
try {
|
||||
const m = await loadMermaid()
|
||||
const id = `mermaid-${getRandomId()}`
|
||||
// v9 (mermaid-legacy): callback-based render(id, code, cb)
|
||||
// v10+: Promise-based render(id, code) → { svg }
|
||||
const svg = mermaidIsLegacy
|
||||
? await new Promise<string>((resolve, reject) => {
|
||||
try {
|
||||
m.render(id, mermaidCode, resolve)
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
: (await m.render(id, mermaidCode)).svg
|
||||
if (gen !== renderGeneration) return
|
||||
container.innerHTML = svg
|
||||
applyFlowchartDisplayStyle(container)
|
||||
renderSuccess.value = true
|
||||
} catch (error) {
|
||||
if (gen !== renderGeneration) return
|
||||
renderError.value =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "流程图渲染失败,请检查代码格式"
|
||||
}
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
renderError.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
renderError: readonly(renderError),
|
||||
renderSuccess: readonly(renderSuccess),
|
||||
renderFlowchart,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
544
apps/web/src/shared/composables/websocket.ts
Normal file
544
apps/web/src/shared/composables/websocket.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
import { ref, onUnmounted, type Ref } from "vue"
|
||||
|
||||
/**
|
||||
* WebSocket 连接状态
|
||||
*/
|
||||
export type ConnectionStatus =
|
||||
"disconnected" | "connecting" | "connected" | "error"
|
||||
|
||||
/**
|
||||
* WebSocket 消息类型
|
||||
*/
|
||||
export interface WebSocketMessage {
|
||||
type: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 配置
|
||||
*/
|
||||
export interface WebSocketConfig {
|
||||
/** WebSocket 路径(如 '/ws/submission/') */
|
||||
path: string
|
||||
/** 最大重连次数,默认 5 */
|
||||
maxReconnectAttempts?: number
|
||||
/** 重连延迟(毫秒),默认 1000 */
|
||||
reconnectDelay?: number
|
||||
/** 心跳间隔(毫秒),默认 30000(30秒) */
|
||||
heartbeatTime?: number
|
||||
/** 是否启用心跳,默认 true */
|
||||
enableHeartbeat?: boolean
|
||||
/** 是否启用自动重连,默认 true */
|
||||
enableAutoReconnect?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 消息处理器
|
||||
*/
|
||||
export type MessageHandler<T extends WebSocketMessage = WebSocketMessage> = (
|
||||
data: T,
|
||||
) => void
|
||||
|
||||
/**
|
||||
* WebSocket 基础连接管理类
|
||||
* 提供连接、重连、心跳等通用功能
|
||||
*/
|
||||
export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
protected ws: WebSocket | null = null
|
||||
protected url: string
|
||||
protected handlers: Set<MessageHandler<T>> = new Set()
|
||||
protected reconnectAttempts = 0
|
||||
protected maxReconnectAttempts: number
|
||||
protected reconnectDelay: number
|
||||
protected heartbeatInterval: number | null = null
|
||||
protected heartbeatTime: number
|
||||
protected enableHeartbeat: boolean
|
||||
protected enableAutoReconnect: boolean
|
||||
protected disconnectTimer: number | null = null
|
||||
|
||||
public status: Ref<ConnectionStatus> = ref<ConnectionStatus>("disconnected")
|
||||
|
||||
constructor(config: WebSocketConfig) {
|
||||
this.url = `${import.meta.env.PUBLIC_WS_URL}/${config.path}/`
|
||||
|
||||
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 5
|
||||
this.reconnectDelay = config.reconnectDelay ?? 1000
|
||||
this.heartbeatTime = config.heartbeatTime ?? 30000
|
||||
this.enableHeartbeat = config.enableHeartbeat ?? true
|
||||
this.enableAutoReconnect = config.enableAutoReconnect ?? true
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 WebSocket
|
||||
*/
|
||||
connect() {
|
||||
if (
|
||||
this.ws &&
|
||||
(this.ws.readyState === WebSocket.OPEN ||
|
||||
this.ws.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.status.value = "connecting"
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.status.value = "connected"
|
||||
this.reconnectAttempts = 0
|
||||
console.log(`[WebSocket] 连接成功: ${this.url}`)
|
||||
if (this.enableHeartbeat) {
|
||||
this.startHeartbeat()
|
||||
}
|
||||
this.onConnected()
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as T
|
||||
console.log(`[WebSocket] 收到消息:`, data)
|
||||
|
||||
// 处理心跳响应
|
||||
if (data.type === "pong") {
|
||||
return
|
||||
}
|
||||
|
||||
// 调用消息处理钩子
|
||||
this.onMessage(data)
|
||||
} catch (error) {
|
||||
console.error("[WebSocket] 解析消息失败:", error)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error("[WebSocket] 连接错误:", error)
|
||||
this.status.value = "error"
|
||||
this.onError(error)
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log(
|
||||
`[WebSocket] 连接关闭: code=${event.code}, reason=${event.reason}`,
|
||||
)
|
||||
this.status.value = "disconnected"
|
||||
this.stopHeartbeat()
|
||||
this.onDisconnected(event)
|
||||
|
||||
// 自动重连
|
||||
if (
|
||||
this.enableAutoReconnect &&
|
||||
this.reconnectAttempts < this.maxReconnectAttempts
|
||||
) {
|
||||
this.reconnectAttempts++
|
||||
const delay = this.reconnectDelay * this.reconnectAttempts
|
||||
console.log(
|
||||
`[WebSocket] 将在 ${delay}ms 后重连 (尝试 ${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
|
||||
)
|
||||
setTimeout(() => this.connect(), delay)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create WebSocket connection:", error)
|
||||
this.status.value = "error"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
disconnect() {
|
||||
this.cancelScheduledDisconnect()
|
||||
this.stopHeartbeat()
|
||||
this.enableAutoReconnect = false // 停止自动重连
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
this.status.value = "disconnected"
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排延迟断开连接
|
||||
* @param delay 延迟时间(毫秒),默认 900000(15分钟)
|
||||
*/
|
||||
scheduleDisconnect(delay: number = 15 * 60 * 1000) {
|
||||
// 取消之前的定时器
|
||||
this.cancelScheduledDisconnect()
|
||||
|
||||
// 设置新的定时器
|
||||
this.disconnectTimer = window.setTimeout(() => {
|
||||
const minutes = Math.floor(delay / 60000)
|
||||
console.log(`WebSocket idle for ${minutes} minutes, disconnecting...`)
|
||||
this.disconnect()
|
||||
// 断开后需要重新允许自动重连
|
||||
this.enableAutoReconnect = true
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消已安排的断开连接
|
||||
*/
|
||||
cancelScheduledDisconnect() {
|
||||
if (this.disconnectTimer !== null) {
|
||||
clearTimeout(this.disconnectTimer)
|
||||
this.disconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
send(data: any) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加消息处理器
|
||||
*/
|
||||
addHandler(handler: MessageHandler<T>) {
|
||||
this.handlers.add(handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除消息处理器
|
||||
*/
|
||||
removeHandler(handler: MessageHandler<T>) {
|
||||
this.handlers.delete(handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有处理器
|
||||
*/
|
||||
clearHandlers() {
|
||||
this.handlers.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送心跳包
|
||||
*/
|
||||
protected sendHeartbeat() {
|
||||
this.send({ type: "ping", timestamp: Date.now() })
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始心跳
|
||||
*/
|
||||
protected startHeartbeat() {
|
||||
this.stopHeartbeat()
|
||||
this.heartbeatInterval = window.setInterval(() => {
|
||||
this.sendHeartbeat()
|
||||
}, this.heartbeatTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止心跳
|
||||
*/
|
||||
protected stopHeartbeat() {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval)
|
||||
this.heartbeatInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接成功钩子(子类可重写)
|
||||
*/
|
||||
protected onConnected() {
|
||||
// 子类实现
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接钩子(子类可重写)
|
||||
*/
|
||||
protected onDisconnected(event: CloseEvent) {
|
||||
// 子类实现
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误钩子(子类可重写)
|
||||
*/
|
||||
protected onError(error: Event) {
|
||||
// 子类实现
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息处理钩子(子类可重写)
|
||||
*/
|
||||
protected onMessage(data: T) {
|
||||
// 通知所有处理器
|
||||
this.handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(data)
|
||||
} catch (error) {
|
||||
console.error("Error in message handler:", error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交状态更新的数据类型
|
||||
*/
|
||||
export interface SubmissionUpdate extends WebSocketMessage {
|
||||
type: "submission_update"
|
||||
submission_id: string
|
||||
result: number
|
||||
status: "pending" | "judging" | "finished" | "error"
|
||||
time_cost?: number
|
||||
memory_cost?: number
|
||||
score?: number
|
||||
err_info?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交 WebSocket 连接管理类
|
||||
*/
|
||||
class SubmissionWebSocket extends BaseWebSocket<SubmissionUpdate> {
|
||||
constructor() {
|
||||
super({
|
||||
path: "submission",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅特定提交的更新
|
||||
*/
|
||||
subscribe(submissionId: string) {
|
||||
const success = this.send({
|
||||
type: "subscribe",
|
||||
submission_id: submissionId,
|
||||
})
|
||||
if (!success) {
|
||||
console.error("[WebSocket] 订阅失败: 连接未就绪")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于组件中使用 WebSocket 的 Composable
|
||||
* 每次调用创建新的 WebSocket 实例
|
||||
*/
|
||||
export function useSubmissionWebSocket(
|
||||
handler?: MessageHandler<SubmissionUpdate>,
|
||||
) {
|
||||
const ws = new SubmissionWebSocket()
|
||||
|
||||
// 如果提供了处理器,添加到实例中
|
||||
if (handler) {
|
||||
ws.addHandler(handler)
|
||||
}
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onUnmounted(() => {
|
||||
if (handler) {
|
||||
ws.removeHandler(handler)
|
||||
}
|
||||
ws.disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
connect: () => ws.connect(),
|
||||
disconnect: () => ws.disconnect(),
|
||||
subscribe: (submissionId: string) => ws.subscribe(submissionId),
|
||||
scheduleDisconnect: (delay?: number) => ws.scheduleDisconnect(delay),
|
||||
cancelScheduledDisconnect: () => ws.cancelScheduledDisconnect(),
|
||||
status: ws.status,
|
||||
addHandler: (h: MessageHandler<SubmissionUpdate>) => ws.addHandler(h),
|
||||
removeHandler: (h: MessageHandler<SubmissionUpdate>) => ws.removeHandler(h),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 WebSocket Composable 工厂函数
|
||||
* 用于创建自定义的 WebSocket composable
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // 创建通知 WebSocket
|
||||
* interface NotificationMessage extends WebSocketMessage {
|
||||
* type: 'notification'
|
||||
* title: string
|
||||
* content: string
|
||||
* }
|
||||
*
|
||||
* class NotificationWebSocket extends BaseWebSocket<NotificationMessage> {
|
||||
* constructor() {
|
||||
* super({ path: 'notification' })
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* let notificationWs: NotificationWebSocket | null = null
|
||||
*
|
||||
* export function useNotificationWebSocket(handler?: MessageHandler<NotificationMessage>) {
|
||||
* if (!notificationWs) {
|
||||
* notificationWs = new NotificationWebSocket()
|
||||
* }
|
||||
* return createWebSocketComposable(notificationWs, handler)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function createWebSocketComposable<T extends WebSocketMessage>(
|
||||
ws: BaseWebSocket<T>,
|
||||
handler?: MessageHandler<T>,
|
||||
) {
|
||||
if (handler) {
|
||||
ws.addHandler(handler)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (handler) {
|
||||
ws.removeHandler(handler)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
connect: () => ws.connect(),
|
||||
disconnect: () => ws.disconnect(),
|
||||
send: (data: any) => ws.send(data),
|
||||
status: ws.status,
|
||||
addHandler: (h: MessageHandler<T>) => ws.addHandler(h),
|
||||
removeHandler: (h: MessageHandler<T>) => ws.removeHandler(h),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程图评分更新消息类型
|
||||
*/
|
||||
export interface FlowchartEvaluationUpdate extends WebSocketMessage {
|
||||
type:
|
||||
| "flowchart_evaluation_completed"
|
||||
| "flowchart_evaluation_failed"
|
||||
| "flowchart_evaluation_update"
|
||||
submission_id: string
|
||||
score?: number
|
||||
grade?: string
|
||||
feedback?: string
|
||||
suggestions?: string
|
||||
criteriaDetails?: any
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程图 WebSocket 连接管理类
|
||||
*/
|
||||
class FlowchartWebSocket extends BaseWebSocket<FlowchartEvaluationUpdate> {
|
||||
constructor() {
|
||||
super({
|
||||
path: "flowchart", // 使用专门的 flowchart WebSocket 路径
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅特定流程图提交的更新
|
||||
*/
|
||||
subscribe(submissionId: string) {
|
||||
const success = this.send({
|
||||
type: "subscribe",
|
||||
submission_id: submissionId,
|
||||
})
|
||||
if (!success) {
|
||||
console.error("[Flowchart WebSocket] 订阅失败: 连接未就绪")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于组件中使用流程图 WebSocket 的 Composable
|
||||
*/
|
||||
export function useFlowchartWebSocket(
|
||||
handler?: MessageHandler<FlowchartEvaluationUpdate>,
|
||||
) {
|
||||
const ws = new FlowchartWebSocket()
|
||||
|
||||
// 如果提供了处理器,添加到实例中
|
||||
if (handler) {
|
||||
ws.addHandler(handler)
|
||||
}
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onUnmounted(() => {
|
||||
if (handler) {
|
||||
ws.removeHandler(handler)
|
||||
}
|
||||
ws.disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
connect: () => ws.connect(),
|
||||
disconnect: () => ws.disconnect(),
|
||||
subscribe: (submissionId: string) => ws.subscribe(submissionId),
|
||||
scheduleDisconnect: (delay?: number) => ws.scheduleDisconnect(delay),
|
||||
cancelScheduledDisconnect: () => ws.cancelScheduledDisconnect(),
|
||||
status: ws.status,
|
||||
addHandler: (h: MessageHandler<FlowchartEvaluationUpdate>) =>
|
||||
ws.addHandler(h),
|
||||
removeHandler: (h: MessageHandler<FlowchartEvaluationUpdate>) =>
|
||||
ws.removeHandler(h),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置更新消息类型
|
||||
*/
|
||||
export interface ConfigUpdate extends WebSocketMessage {
|
||||
type: "config_update"
|
||||
key: string
|
||||
value: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置 WebSocket 连接管理类
|
||||
*/
|
||||
class ConfigWebSocket extends BaseWebSocket<ConfigUpdate> {
|
||||
constructor() {
|
||||
super({
|
||||
path: "config",
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送配置更新
|
||||
*/
|
||||
updateConfig(key: string, value: any) {
|
||||
this.send({
|
||||
type: "config_update",
|
||||
key,
|
||||
value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于组件中使用配置 WebSocket 的 Composable
|
||||
*/
|
||||
export function useConfigWebSocket(handler?: MessageHandler<ConfigUpdate>) {
|
||||
const ws = new ConfigWebSocket()
|
||||
|
||||
onMounted(() => {
|
||||
if (handler) {
|
||||
ws.addHandler(handler)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (handler) {
|
||||
ws.removeHandler(handler)
|
||||
}
|
||||
ws.disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
connect: () => ws.connect(),
|
||||
disconnect: () => ws.disconnect(),
|
||||
updateConfig: (key: string, value: any) => ws.updateConfig(key, value),
|
||||
status: ws.status,
|
||||
addHandler: (h: MessageHandler<ConfigUpdate>) => ws.addHandler(h),
|
||||
removeHandler: (h: MessageHandler<ConfigUpdate>) => ws.removeHandler(h),
|
||||
}
|
||||
}
|
||||
107
apps/web/src/shared/extensions/autocompletion.ts
Normal file
107
apps/web/src/shared/extensions/autocompletion.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
Completion,
|
||||
CompletionContext,
|
||||
CompletionResult,
|
||||
CompletionSource,
|
||||
} from "@codemirror/autocomplete"
|
||||
import type { EditorView } from "@codemirror/view"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import type { LANGUAGE } from "utils/types"
|
||||
import { c } from "./c"
|
||||
import { python } from "./python"
|
||||
import { sql } from "./sql"
|
||||
|
||||
type ChineseCompletion = Pick<
|
||||
Completion,
|
||||
"label" | "detail" | "type" | "info" | "boost" | "apply"
|
||||
> & { apply?: string | Completion["apply"] }
|
||||
|
||||
// 中文注释提示
|
||||
const chineseAnnotations: Record<string, ChineseCompletion[]> = {
|
||||
python,
|
||||
c,
|
||||
sql,
|
||||
}
|
||||
|
||||
// SQL 题:当前题目的表名和字段名补全,数据来自 sql_display
|
||||
function sqlSchemaCompletions(): Completion[] {
|
||||
const tables = useProblemStore().problem?.sql_display?.tables ?? []
|
||||
return tables.flatMap((table) => [
|
||||
{
|
||||
label: table.name,
|
||||
detail: "数据表",
|
||||
type: "class",
|
||||
info: `字段:${table.columns
|
||||
.map((col) => (col.type ? `${col.name} ${col.type}` : col.name))
|
||||
.join(", ")}`,
|
||||
boost: 110,
|
||||
},
|
||||
...table.columns.map((col) => ({
|
||||
label: col.name,
|
||||
detail: col.type
|
||||
? `${table.name} 的字段 · ${col.type}`
|
||||
: `${table.name} 的字段`,
|
||||
type: "property",
|
||||
boost: 105,
|
||||
})),
|
||||
])
|
||||
}
|
||||
|
||||
export function enhanceCompletion(language: LANGUAGE): CompletionSource {
|
||||
return async function (
|
||||
context: CompletionContext,
|
||||
): Promise<CompletionResult | null> {
|
||||
const word = context.matchBefore(/\w+/)
|
||||
if (!word && !context.explicit) return null
|
||||
|
||||
const trulyLanguage =
|
||||
language === "SQL"
|
||||
? "sql"
|
||||
: language.startsWith("Python")
|
||||
? "python"
|
||||
: "c"
|
||||
const completions: Completion[] = (
|
||||
chineseAnnotations[trulyLanguage] || []
|
||||
).map((completion) => {
|
||||
const insertText =
|
||||
typeof completion.apply === "string"
|
||||
? completion.apply
|
||||
: completion.label
|
||||
const cursorOffset = insertText.includes("(")
|
||||
? insertText.indexOf("(") + 1
|
||||
: insertText.length
|
||||
|
||||
if (
|
||||
(completion.type === "function" || completion.type === "method") &&
|
||||
insertText.includes(")")
|
||||
) {
|
||||
return {
|
||||
...completion,
|
||||
apply: (
|
||||
view: EditorView,
|
||||
_c: Completion,
|
||||
from: number,
|
||||
to: number,
|
||||
) => {
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: insertText },
|
||||
selection: { anchor: from + cursorOffset },
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return completion
|
||||
})
|
||||
|
||||
if (trulyLanguage === "sql") {
|
||||
completions.push(...sqlSchemaCompletions())
|
||||
}
|
||||
|
||||
return {
|
||||
from: word ? word.from : context.pos,
|
||||
options: completions,
|
||||
validFor: /^\w+$/,
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/web/src/shared/extensions/baseTheme.ts
Normal file
9
apps/web/src/shared/extensions/baseTheme.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { EditorView } from "@codemirror/view"
|
||||
|
||||
export const styleTheme = EditorView.baseTheme({
|
||||
"& .cm-scroller": { "font-family": "Monaco" },
|
||||
"&.cm-editor.cm-focused": { outline: "none" },
|
||||
"&.cm-editor .cm-tooltip.cm-tooltip-autocomplete ul": {
|
||||
"font-family": "Monaco",
|
||||
},
|
||||
})
|
||||
257
apps/web/src/shared/extensions/c.ts
Normal file
257
apps/web/src/shared/extensions/c.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
export const c = [
|
||||
{
|
||||
label: "printf",
|
||||
detail: "格式化输出",
|
||||
type: "function",
|
||||
info: "标准输出函数,按格式打印,搭配 %d/%s 等占位符",
|
||||
boost: 90,
|
||||
apply: "printf();",
|
||||
},
|
||||
{
|
||||
label: "scanf",
|
||||
detail: "格式化输入",
|
||||
type: "function",
|
||||
info: "标准输入函数,按格式读取,用 & 取地址接收变量",
|
||||
boost: 88,
|
||||
apply: "scanf();",
|
||||
},
|
||||
{
|
||||
label: "puts",
|
||||
detail: "输出字符串",
|
||||
type: "function",
|
||||
info: "输出以 \\0 结尾的字符串并自动换行,比 printf 简洁",
|
||||
boost: 84,
|
||||
apply: "puts();",
|
||||
},
|
||||
{
|
||||
label: "gets",
|
||||
detail: "读取字符串",
|
||||
type: "function",
|
||||
info: "读取一行字符串到缓冲区(不安全,建议使用 fgets)",
|
||||
boost: 60,
|
||||
apply: "gets();",
|
||||
},
|
||||
{
|
||||
label: "fgets",
|
||||
detail: "安全读行",
|
||||
type: "function",
|
||||
info: "从文件流读取一行到缓冲区,限制长度,避免溢出",
|
||||
boost: 82,
|
||||
apply: "fgets();",
|
||||
},
|
||||
{
|
||||
label: "memset",
|
||||
detail: "内存填充",
|
||||
type: "function",
|
||||
info: "按字节把内存填为指定值,常用于初始化数组或结构体",
|
||||
boost: 80,
|
||||
apply: "memset();",
|
||||
},
|
||||
{
|
||||
label: "memcpy",
|
||||
detail: "内存拷贝",
|
||||
type: "function",
|
||||
info: "从源地址复制指定字节到目标地址,避免重叠",
|
||||
boost: 78,
|
||||
apply: "memcpy();",
|
||||
},
|
||||
{
|
||||
label: "strlen",
|
||||
detail: "字符串长度",
|
||||
type: "function",
|
||||
info: "计算以 \\0 结尾的字符串长度(不含终止符)",
|
||||
boost: 76,
|
||||
apply: "strlen();",
|
||||
},
|
||||
{
|
||||
label: "strcmp",
|
||||
detail: "字符串比较",
|
||||
type: "function",
|
||||
info: "按字典序比较两个字符串,相等为 0,小于返回负数",
|
||||
boost: 74,
|
||||
apply: "strcmp();",
|
||||
},
|
||||
{
|
||||
label: "strcpy",
|
||||
detail: "字符串拷贝",
|
||||
type: "function",
|
||||
info: "把源字符串复制到目标(含终止符),目标要有足够空间",
|
||||
boost: 72,
|
||||
apply: "strcpy();",
|
||||
},
|
||||
{
|
||||
label: "int main",
|
||||
detail: "程序入口",
|
||||
type: "keyword",
|
||||
info: "C 程序入口,通常返回 0 表示正常退出",
|
||||
boost: 70,
|
||||
},
|
||||
{
|
||||
label: "for",
|
||||
detail: "循环语句",
|
||||
type: "keyword",
|
||||
info: "for (init; condition; step) 结构,用于固定次数循环",
|
||||
boost: 68,
|
||||
},
|
||||
{
|
||||
label: "while",
|
||||
detail: "条件循环",
|
||||
type: "keyword",
|
||||
info: "while (condition) 条件循环,条件真则执行",
|
||||
boost: 66,
|
||||
},
|
||||
{
|
||||
label: "if",
|
||||
detail: "条件判断",
|
||||
type: "keyword",
|
||||
info: "if (condition) 条件分支,可配 else/else if",
|
||||
boost: 64,
|
||||
},
|
||||
{
|
||||
label: "struct",
|
||||
detail: "结构体定义",
|
||||
type: "keyword",
|
||||
info: "定义结构体,可组合不同类型的成员",
|
||||
boost: 62,
|
||||
},
|
||||
{
|
||||
label: "typedef",
|
||||
detail: "类型别名",
|
||||
type: "keyword",
|
||||
info: "为已有类型起别名,提升可读性",
|
||||
boost: 60,
|
||||
},
|
||||
{
|
||||
label: "const",
|
||||
detail: "只读限定",
|
||||
type: "keyword",
|
||||
info: "声明常量或只读指针,防止被修改",
|
||||
boost: 58,
|
||||
},
|
||||
{
|
||||
label: "return",
|
||||
detail: "返回值",
|
||||
type: "keyword",
|
||||
info: "结束函数并返回值,main 返回 0 表示成功",
|
||||
boost: 56,
|
||||
},
|
||||
{
|
||||
label: "break",
|
||||
detail: "终止循环",
|
||||
type: "keyword",
|
||||
info: "立即退出最近的 for/while/do 循环体",
|
||||
boost: 54,
|
||||
},
|
||||
{
|
||||
label: "continue",
|
||||
detail: "继续下一次循环",
|
||||
type: "keyword",
|
||||
info: "跳过当前循环余下语句,开始下一轮迭代",
|
||||
boost: 52,
|
||||
},
|
||||
{
|
||||
label: "do",
|
||||
detail: "do-while 循环",
|
||||
type: "keyword",
|
||||
info: "do { ... } while(condition); 后置条件循环,至少执行一次",
|
||||
boost: 50,
|
||||
},
|
||||
{
|
||||
label: "else",
|
||||
detail: "分支兜底",
|
||||
type: "keyword",
|
||||
info: "if/else if/else 结构中的兜底分支",
|
||||
boost: 48,
|
||||
},
|
||||
{
|
||||
label: "switch",
|
||||
detail: "多分支选择",
|
||||
type: "keyword",
|
||||
info: "switch (expr) { case ... } 多分支选择结构,常配合 break",
|
||||
boost: 46,
|
||||
},
|
||||
{
|
||||
label: "case",
|
||||
detail: "分支标签",
|
||||
type: "keyword",
|
||||
info: "switch 中的具体匹配分支,常在末尾使用 break",
|
||||
boost: 44,
|
||||
},
|
||||
{
|
||||
label: "default",
|
||||
detail: "默认分支",
|
||||
type: "keyword",
|
||||
info: "switch 中未匹配任何 case 时执行的分支",
|
||||
boost: 42,
|
||||
},
|
||||
{
|
||||
label: "goto",
|
||||
detail: "无条件跳转",
|
||||
type: "keyword",
|
||||
info: "跳转到指定标签位置,需谨慎使用以避免可读性问题",
|
||||
boost: 40,
|
||||
},
|
||||
{
|
||||
label: "sizeof",
|
||||
detail: "求字节大小",
|
||||
type: "keyword",
|
||||
info: "返回类型或表达式所占字节数,编译期求值",
|
||||
boost: 38,
|
||||
},
|
||||
{
|
||||
label: "int",
|
||||
detail: "整数类型",
|
||||
type: "keyword",
|
||||
info: "声明整型变量或返回值,通常占 4 字节",
|
||||
boost: 36,
|
||||
},
|
||||
{
|
||||
label: "char",
|
||||
detail: "字符类型",
|
||||
type: "keyword",
|
||||
info: "声明字符或字节型变量,通常占 1 字节",
|
||||
boost: 34,
|
||||
},
|
||||
{
|
||||
label: "double",
|
||||
detail: "双精度类型",
|
||||
type: "keyword",
|
||||
info: "声明双精度浮点变量,通常占 8 字节",
|
||||
boost: 32,
|
||||
},
|
||||
{
|
||||
label: "float",
|
||||
detail: "单精度类型",
|
||||
type: "keyword",
|
||||
info: "声明单精度浮点变量,通常占 4 字节",
|
||||
boost: 30,
|
||||
},
|
||||
{
|
||||
label: "void",
|
||||
detail: "空类型",
|
||||
type: "keyword",
|
||||
info: "表示无返回值或无类型指针(void*)",
|
||||
boost: 28,
|
||||
},
|
||||
{
|
||||
label: "unsigned",
|
||||
detail: "无符号修饰",
|
||||
type: "keyword",
|
||||
info: "与整型/字符型组合表示无符号数,扩大正数范围",
|
||||
boost: 26,
|
||||
},
|
||||
{
|
||||
label: "long",
|
||||
detail: "长整型修饰",
|
||||
type: "keyword",
|
||||
info: "与整型组合表示更大范围(long/long long)",
|
||||
boost: 24,
|
||||
},
|
||||
{
|
||||
label: "short",
|
||||
detail: "短整型修饰",
|
||||
type: "keyword",
|
||||
info: "与整型组合表示较小范围的整数类型",
|
||||
boost: 22,
|
||||
},
|
||||
]
|
||||
608
apps/web/src/shared/extensions/python.ts
Normal file
608
apps/web/src/shared/extensions/python.ts
Normal file
@@ -0,0 +1,608 @@
|
||||
export const python = [
|
||||
{
|
||||
label: "print",
|
||||
detail: "打印输出",
|
||||
type: "function",
|
||||
info: "最常用的输出函数,把内容打印到屏幕;sep 控制分隔符,end 控制行尾。",
|
||||
boost: 100,
|
||||
apply: "print()",
|
||||
},
|
||||
{
|
||||
label: "input",
|
||||
detail: "读取输入",
|
||||
type: "function",
|
||||
info: "读取一行输入并返回字符串,可以传入提示文字。",
|
||||
boost: 99,
|
||||
apply: "input()",
|
||||
},
|
||||
{
|
||||
label: "len",
|
||||
detail: "获取长度",
|
||||
type: "function",
|
||||
info: "返回序列或集合的长度,常用来统计列表、字符串中有多少个元素。",
|
||||
boost: 90,
|
||||
apply: "len()",
|
||||
},
|
||||
{
|
||||
label: "range",
|
||||
detail: "生成整数序列",
|
||||
type: "function",
|
||||
info: "生成整数序列,支持起点、终点和步长,常配合 for 循环遍历次数。",
|
||||
boost: 85,
|
||||
apply: "range()",
|
||||
},
|
||||
{
|
||||
label: "enumerate",
|
||||
detail: "枚举索引与值",
|
||||
type: "function",
|
||||
info: "遍历时同时得到索引和值,可用 start 指定起始编号,适合需要编号输出的场景。",
|
||||
boost: 82,
|
||||
apply: "enumerate()",
|
||||
},
|
||||
{
|
||||
label: "zip",
|
||||
detail: "并行遍历",
|
||||
type: "function",
|
||||
info: "把多个可迭代对象按位置打包成元组并行遍历,长度取最短的序列。",
|
||||
boost: 80,
|
||||
apply: "zip()",
|
||||
},
|
||||
{
|
||||
label: "map",
|
||||
detail: "映射函数",
|
||||
type: "function",
|
||||
info: "把函数作用到序列每个元素,返回惰性迭代器,需要 list() 展开后才能看到结果。",
|
||||
boost: 78,
|
||||
apply: "map()",
|
||||
},
|
||||
{
|
||||
label: "filter",
|
||||
detail: "过滤元素",
|
||||
type: "function",
|
||||
info: "保留函数返回真值的元素,返回惰性迭代器,常用于筛选出符合条件的数据。",
|
||||
boost: 76,
|
||||
apply: "filter()",
|
||||
},
|
||||
{
|
||||
label: "sorted",
|
||||
detail: "排序",
|
||||
type: "function",
|
||||
info: "返回排好序的新列表,支持 key 排序函数和 reverse 逆序,不会修改原序列。",
|
||||
boost: 74,
|
||||
apply: "sorted()",
|
||||
},
|
||||
{
|
||||
label: "sum",
|
||||
detail: "求和",
|
||||
type: "function",
|
||||
info: "对可迭代对象求和,可设置初始值,常用于数字累计与前缀和。",
|
||||
boost: 72,
|
||||
apply: "sum()",
|
||||
},
|
||||
{
|
||||
label: "open",
|
||||
detail: "文件读写",
|
||||
type: "function",
|
||||
info: "打开文件获得文件对象,常配合 with 自动关闭,支持读写追加等多种模式。",
|
||||
boost: 70,
|
||||
apply: "open()",
|
||||
},
|
||||
{
|
||||
label: "abs",
|
||||
detail: "绝对值",
|
||||
type: "function",
|
||||
info: "返回数字的绝对值,把负数变成正数,常用于距离或差值计算。",
|
||||
boost: 68,
|
||||
apply: "abs()",
|
||||
},
|
||||
{
|
||||
label: "round",
|
||||
detail: "四舍五入",
|
||||
type: "function",
|
||||
info: "按指定小数位四舍五入,默认保留到整数,适合处理成绩或金额保留位数。",
|
||||
boost: 66,
|
||||
apply: "round()",
|
||||
},
|
||||
{
|
||||
label: "isinstance",
|
||||
detail: "类型检查",
|
||||
type: "function",
|
||||
info: "判断对象是否属于某个类型或类型元组,常用于分支处理不同数据。",
|
||||
boost: 64,
|
||||
apply: "isinstance()",
|
||||
},
|
||||
{
|
||||
label: "type",
|
||||
detail: "获取类型",
|
||||
type: "function",
|
||||
info: "返回对象的类型,或用三个参数动态创建类型,用来了解变量真实类型。",
|
||||
boost: 62,
|
||||
apply: "type()",
|
||||
},
|
||||
{
|
||||
label: "list",
|
||||
detail: "列表构造",
|
||||
type: "function",
|
||||
info: "把可迭代对象转换为列表,或创建空列表存放数据,支持列表推导式。",
|
||||
boost: 60,
|
||||
apply: "list()",
|
||||
},
|
||||
{
|
||||
label: "dict",
|
||||
detail: "字典构造",
|
||||
type: "function",
|
||||
info: "根据映射或键值对序列创建字典,用来存储键值对信息。",
|
||||
boost: 58,
|
||||
apply: "dict()",
|
||||
},
|
||||
{
|
||||
label: "set",
|
||||
detail: "集合构造",
|
||||
type: "function",
|
||||
info: "把可迭代对象转换为集合,自动去重,适合判重和集合运算。",
|
||||
boost: 56,
|
||||
apply: "set()",
|
||||
},
|
||||
{
|
||||
label: "tuple",
|
||||
detail: "元组构造",
|
||||
type: "function",
|
||||
info: "把可迭代对象转换为元组,或创建不可变的序列,用作安全的组合数据。",
|
||||
boost: 54,
|
||||
apply: "tuple()",
|
||||
},
|
||||
{
|
||||
label: "int",
|
||||
detail: "转整数",
|
||||
type: "function",
|
||||
info: "把参数转为整数,支持进制转换,如 int('101', 2) 表示二进制转十进制。",
|
||||
boost: 74,
|
||||
apply: "int()",
|
||||
},
|
||||
{
|
||||
label: "float",
|
||||
detail: "转浮点数",
|
||||
type: "function",
|
||||
info: "把参数转为浮点数,接受字符串或数字,常用于保留小数的计算。",
|
||||
boost: 72,
|
||||
apply: "float()",
|
||||
},
|
||||
{
|
||||
label: "str",
|
||||
detail: "转字符串",
|
||||
type: "function",
|
||||
info: "把对象转为字符串,常用于输出、拼接或写入文件。",
|
||||
boost: 70,
|
||||
apply: "str()",
|
||||
},
|
||||
{
|
||||
label: "bool",
|
||||
detail: "转布尔值",
|
||||
type: "function",
|
||||
info: "按真值规则转为 True/False,空对象一般为 False,常用于条件判断。",
|
||||
boost: 68,
|
||||
apply: "bool()",
|
||||
},
|
||||
{
|
||||
label: "def",
|
||||
detail: "定义函数",
|
||||
type: "keyword",
|
||||
info: "定义函数,可写位置参数、关键字参数和默认值,是封装和复用代码的基础。",
|
||||
boost: 52,
|
||||
},
|
||||
{
|
||||
label: "class",
|
||||
detail: "定义类",
|
||||
type: "keyword",
|
||||
info: "定义类,支持继承和魔术方法,用来创建自定义数据类型。",
|
||||
boost: 50,
|
||||
},
|
||||
{
|
||||
label: "with",
|
||||
detail: "上下文管理",
|
||||
type: "keyword",
|
||||
info: "进入上下文管理器,自动处理进入与退出,常用于文件、锁等需要收尾的资源。",
|
||||
boost: 48,
|
||||
},
|
||||
{
|
||||
label: "try",
|
||||
detail: "异常捕获",
|
||||
type: "keyword",
|
||||
info: "开始异常处理代码块,后面接 except/finally/else,防止程序因错误直接退出。",
|
||||
boost: 46,
|
||||
},
|
||||
{
|
||||
label: "except",
|
||||
detail: "处理异常",
|
||||
type: "keyword",
|
||||
info: "捕获并处理指定异常,与 try 连用,让程序能优雅处理输入或运行时错误。",
|
||||
boost: 44,
|
||||
},
|
||||
{
|
||||
label: "finally",
|
||||
detail: "收尾",
|
||||
type: "keyword",
|
||||
info: "无论是否出现异常都会执行的收尾代码块,常用于关闭文件或释放资源。",
|
||||
boost: 42,
|
||||
},
|
||||
{
|
||||
label: "import",
|
||||
detail: "导入模块",
|
||||
type: "keyword",
|
||||
info: "导入模块或包中的名称,可用 as 取别名,便于使用标准库或第三方库。",
|
||||
boost: 40,
|
||||
},
|
||||
{
|
||||
label: "from",
|
||||
detail: "按需导入",
|
||||
type: "keyword",
|
||||
info: "从模块中按名称导入对象,可与 import/as 组合,减少书写模块前缀。",
|
||||
boost: 38,
|
||||
},
|
||||
{
|
||||
label: "return",
|
||||
detail: "返回值",
|
||||
type: "keyword",
|
||||
info: "结束函数并返回一个值,不写时默认返回 None。",
|
||||
boost: 36,
|
||||
},
|
||||
{
|
||||
label: "for",
|
||||
detail: "for 循环",
|
||||
type: "keyword",
|
||||
info: "逐项遍历,可以配合 range 使用。",
|
||||
boost: 45,
|
||||
},
|
||||
{
|
||||
label: "while",
|
||||
detail: "while 循环",
|
||||
type: "keyword",
|
||||
info: "条件循环,条件为真时执行,支持 break/continue 和 else 分支。",
|
||||
boost: 43,
|
||||
},
|
||||
{
|
||||
label: "if",
|
||||
detail: "条件分支",
|
||||
type: "keyword",
|
||||
info: "if 判断,条件成立执行对应语句,可与 elif/else 组成多分支。",
|
||||
boost: 41,
|
||||
apply: "if ",
|
||||
},
|
||||
{
|
||||
label: "elif",
|
||||
detail: "多分支判断",
|
||||
type: "keyword",
|
||||
info: "否则如果,用来依次判断多个条件,让逻辑更清晰。",
|
||||
boost: 39,
|
||||
apply: "elif ",
|
||||
},
|
||||
{
|
||||
label: "else",
|
||||
detail: "否则",
|
||||
type: "keyword",
|
||||
info: "否则,在前面条件不满足或循环未提前退出时执行。",
|
||||
boost: 37,
|
||||
apply: "else:",
|
||||
},
|
||||
{
|
||||
label: "break",
|
||||
detail: "退出循环",
|
||||
type: "keyword",
|
||||
info: "立即终止最近的循环(for 或 while),跳出当前循环体。",
|
||||
boost: 35,
|
||||
},
|
||||
{
|
||||
label: "continue",
|
||||
detail: "跳过本次",
|
||||
type: "keyword",
|
||||
info: "结束本轮循环迭代,直接开始下一轮条件判断。",
|
||||
boost: 33,
|
||||
},
|
||||
{
|
||||
label: "pass",
|
||||
detail: "空操作占位",
|
||||
type: "keyword",
|
||||
info: "占位语句,不执行任何操作,常用于占位或编写空的函数/类体。",
|
||||
boost: 31,
|
||||
},
|
||||
{
|
||||
label: "lambda",
|
||||
detail: "匿名函数",
|
||||
type: "keyword",
|
||||
info: "定义轻量级匿名函数,语法 lambda 参数: 表达式,返回表达式结果,适合简短逻辑。",
|
||||
boost: 29,
|
||||
},
|
||||
{
|
||||
label: "yield",
|
||||
detail: "生成器产出",
|
||||
type: "keyword",
|
||||
info: "在函数中产出一个值并暂停状态,把函数变成生成器,可配合 yield from 继续产出。",
|
||||
boost: 27,
|
||||
},
|
||||
{
|
||||
label: "global",
|
||||
detail: "声明全局变量",
|
||||
type: "keyword",
|
||||
info: "在函数内部声明写入模块级变量的权限,用于修改外层的全局变量。",
|
||||
boost: 25,
|
||||
},
|
||||
{
|
||||
label: "nonlocal",
|
||||
detail: "声明外层变量",
|
||||
type: "keyword",
|
||||
info: "在嵌套函数中声明使用最近外层作用域的变量,便于修改闭包变量。",
|
||||
boost: 23,
|
||||
},
|
||||
{
|
||||
label: "True",
|
||||
detail: "布尔真",
|
||||
type: "keyword",
|
||||
info: "布尔常量 True,与 False/None 一起常用于条件表达式和逻辑判断。",
|
||||
boost: 21,
|
||||
},
|
||||
{
|
||||
label: "False",
|
||||
detail: "布尔假",
|
||||
type: "keyword",
|
||||
info: "布尔常量 False,在逻辑判断中表示假值,常作为条件的否定结果。",
|
||||
boost: 19,
|
||||
},
|
||||
{
|
||||
label: "None",
|
||||
detail: "空值对象",
|
||||
type: "keyword",
|
||||
info: "表示空值或缺失值的单例对象,常用于默认参数或占位。",
|
||||
boost: 17,
|
||||
},
|
||||
{
|
||||
label: "and",
|
||||
detail: "逻辑与",
|
||||
type: "keyword",
|
||||
info: "逻辑与运算符,短路求值,返回最后被求值的操作数,常用于多条件同时成立。",
|
||||
boost: 15,
|
||||
},
|
||||
{
|
||||
label: "or",
|
||||
detail: "逻辑或",
|
||||
type: "keyword",
|
||||
info: "逻辑或运算符,短路求值,返回第一个真值或最后一个操作数,常用于设默认值。",
|
||||
boost: 14,
|
||||
},
|
||||
{
|
||||
label: "not",
|
||||
detail: "逻辑非",
|
||||
type: "keyword",
|
||||
info: "逻辑非运算符,返回布尔取反结果,把真变假、假变真。",
|
||||
boost: 13,
|
||||
},
|
||||
{
|
||||
label: "in",
|
||||
detail: "成员测试",
|
||||
type: "keyword",
|
||||
info: "判断元素是否在序列、集合或字典中,返回布尔结果,常用于查找。",
|
||||
boost: 12,
|
||||
},
|
||||
{
|
||||
label: "is",
|
||||
detail: "身份比较",
|
||||
type: "keyword",
|
||||
info: "比较两个对象是否是同一个对象,常用于和 None 比较以避免误判。",
|
||||
boost: 11,
|
||||
},
|
||||
{
|
||||
label: "append",
|
||||
detail: "列表追加",
|
||||
type: "method",
|
||||
info: "在列表尾部添加一个新元素,等价于 list.append(value)。",
|
||||
boost: 48,
|
||||
apply: "append()",
|
||||
},
|
||||
{
|
||||
label: "insert",
|
||||
detail: "列表插入",
|
||||
type: "method",
|
||||
info: "在指定位置插入元素,语法 list.insert(index, value)。",
|
||||
boost: 46,
|
||||
apply: "insert()",
|
||||
},
|
||||
{
|
||||
label: "remove",
|
||||
detail: "删除匹配值",
|
||||
type: "method",
|
||||
info: "删除列表中第一次出现的指定值,不存在会抛出异常 ValueError。",
|
||||
boost: 44,
|
||||
apply: "remove()",
|
||||
},
|
||||
{
|
||||
label: "pop",
|
||||
detail: "弹出元素",
|
||||
type: "method",
|
||||
info: "移除并返回列表指定位置(默认尾部)的元素,用于栈或队列操作。",
|
||||
boost: 42,
|
||||
apply: "pop()",
|
||||
},
|
||||
{
|
||||
label: "count",
|
||||
detail: "统计次数",
|
||||
type: "method",
|
||||
info: "返回某个对象在列表中出现的次数,常用于频次统计。",
|
||||
boost: 40,
|
||||
apply: "count()",
|
||||
},
|
||||
{
|
||||
label: "reverse",
|
||||
detail: "反转列表",
|
||||
type: "method",
|
||||
info: "原地反转列表中元素的顺序,常与切片 [::-1] 效果类似。",
|
||||
boost: 38,
|
||||
apply: "reverse()",
|
||||
},
|
||||
{
|
||||
label: "sort",
|
||||
detail: "列表排序",
|
||||
type: "method",
|
||||
info: "对列表进行原地排序,可指定 key 排序函数和 reverse 是否倒序。",
|
||||
boost: 36,
|
||||
apply: "sort()",
|
||||
},
|
||||
{
|
||||
label: "add",
|
||||
detail: "集合添加",
|
||||
type: "method",
|
||||
info: "向集合添加单个元素,若元素已存在则忽略,保持集合去重特性。",
|
||||
boost: 50,
|
||||
apply: "add()",
|
||||
},
|
||||
{
|
||||
label: "clear",
|
||||
detail: "清空集合",
|
||||
type: "method",
|
||||
info: "移除集合中所有元素,变成一个空集合。",
|
||||
boost: 48,
|
||||
apply: "clear()",
|
||||
},
|
||||
{
|
||||
label: "keys",
|
||||
detail: "字典键",
|
||||
type: "method",
|
||||
info: "返回字典键的可迭代视图,用于遍历所有键。",
|
||||
boost: 42,
|
||||
apply: "keys()",
|
||||
},
|
||||
{
|
||||
label: "values",
|
||||
detail: "字典值",
|
||||
type: "method",
|
||||
info: "返回字典值的可迭代视图,用于遍历所有值。",
|
||||
boost: 40,
|
||||
apply: "values()",
|
||||
},
|
||||
{
|
||||
label: "split",
|
||||
detail: "字符串切分",
|
||||
type: "method",
|
||||
info: "按分隔符切分字符串,返回列表,默认按空白字符分割。",
|
||||
boost: 52,
|
||||
apply: "split()",
|
||||
},
|
||||
{
|
||||
label: "replace",
|
||||
detail: "字符串替换",
|
||||
type: "method",
|
||||
info: "把字符串中的子串替换为新内容,可限制替换次数。",
|
||||
boost: 50,
|
||||
apply: "replace()",
|
||||
},
|
||||
{
|
||||
label: "format",
|
||||
detail: "格式化字符串",
|
||||
type: "method",
|
||||
info: "使用占位符或命名参数进行字符串格式化,便于拼接变量输出。",
|
||||
boost: 48,
|
||||
apply: "format()",
|
||||
},
|
||||
{
|
||||
label: "strip",
|
||||
detail: "去首尾字符",
|
||||
type: "method",
|
||||
info: "移除字符串首尾指定字符,默认移除空白符,常用于清理输入。",
|
||||
boost: 46,
|
||||
apply: "strip()",
|
||||
},
|
||||
{
|
||||
label: "lower",
|
||||
detail: "转小写",
|
||||
type: "method",
|
||||
info: "把字符串中的字母转换为小写形式,常用于不区分大小写比较。",
|
||||
boost: 44,
|
||||
apply: "lower()",
|
||||
},
|
||||
{
|
||||
label: "upper",
|
||||
detail: "转大写",
|
||||
type: "method",
|
||||
info: "把字符串中的字母转换为大写形式。",
|
||||
boost: 42,
|
||||
apply: "upper()",
|
||||
},
|
||||
{
|
||||
label: "swapcase",
|
||||
detail: "大小写互换",
|
||||
type: "method",
|
||||
info: "把字符串中的大小写字母互换,便于切换展示风格。",
|
||||
boost: 40,
|
||||
apply: "swapcase()",
|
||||
},
|
||||
{
|
||||
label: "find",
|
||||
detail: "查找子串位置",
|
||||
type: "method",
|
||||
info: "返回子串首次出现的索引,未找到返回 -1,适合安全查找。",
|
||||
boost: 38,
|
||||
apply: "find()",
|
||||
},
|
||||
{
|
||||
label: "index",
|
||||
detail: "查找子串索引",
|
||||
type: "method",
|
||||
info: "返回子串首次出现的索引,未找到会抛出异常 ValueError。",
|
||||
boost: 36,
|
||||
apply: "index()",
|
||||
},
|
||||
{
|
||||
label: "startswith",
|
||||
detail: "前缀判断",
|
||||
type: "method",
|
||||
info: "判断字符串是否以指定前缀开头,可指定检查的范围切片。",
|
||||
boost: 34,
|
||||
apply: "startswith()",
|
||||
},
|
||||
{
|
||||
label: "endswith",
|
||||
detail: "后缀判断",
|
||||
type: "method",
|
||||
info: "判断字符串是否以指定后缀结尾,可指定范围,常用于文件名处理。",
|
||||
boost: 32,
|
||||
apply: "endswith()",
|
||||
},
|
||||
{
|
||||
label: "isalnum",
|
||||
detail: "是否字母数字",
|
||||
type: "method",
|
||||
info: "检测字符串是否只由字母和数字组成,常用于基础输入校验。",
|
||||
boost: 30,
|
||||
apply: "isalnum()",
|
||||
},
|
||||
{
|
||||
label: "isalpha",
|
||||
detail: "是否字母",
|
||||
type: "method",
|
||||
info: "检测字符串是否只由字母组成,用来判断名字等只含字母的场景。",
|
||||
boost: 28,
|
||||
apply: "isalpha()",
|
||||
},
|
||||
{
|
||||
label: "isdigit",
|
||||
detail: "是否数字",
|
||||
type: "method",
|
||||
info: "检测字符串是否只由数字组成,用来判断输入是否为纯数字。",
|
||||
boost: 26,
|
||||
apply: "isdigit()",
|
||||
},
|
||||
{
|
||||
label: "islower",
|
||||
detail: "是否全小写",
|
||||
type: "method",
|
||||
info: "检测字符串是否全部由小写字母组成且至少有一个字母。",
|
||||
boost: 24,
|
||||
apply: "islower()",
|
||||
},
|
||||
{
|
||||
label: "isupper",
|
||||
detail: "是否全大写",
|
||||
type: "method",
|
||||
info: "检测字符串中所有字母是否都是大写且至少有一个字母。",
|
||||
boost: 22,
|
||||
apply: "isupper()",
|
||||
},
|
||||
]
|
||||
404
apps/web/src/shared/extensions/sql.ts
Normal file
404
apps/web/src/shared/extensions/sql.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
export const sql = [
|
||||
{
|
||||
label: "SELECT",
|
||||
detail: "查询数据",
|
||||
type: "keyword",
|
||||
info: "从表中查询数据,后面接要查询的列名,用 * 表示所有列。",
|
||||
boost: 100,
|
||||
apply: "SELECT ",
|
||||
},
|
||||
{
|
||||
label: "FROM",
|
||||
detail: "指定表",
|
||||
type: "keyword",
|
||||
info: "指定要查询的表,和 SELECT 搭配使用,如 SELECT * FROM 表名。",
|
||||
boost: 98,
|
||||
apply: "FROM ",
|
||||
},
|
||||
{
|
||||
label: "WHERE",
|
||||
detail: "筛选条件",
|
||||
type: "keyword",
|
||||
info: "按条件筛选行,只保留满足条件的数据,如 WHERE age > 18。",
|
||||
boost: 96,
|
||||
apply: "WHERE ",
|
||||
},
|
||||
{
|
||||
label: "ORDER BY",
|
||||
detail: "排序",
|
||||
type: "keyword",
|
||||
info: "按指定列排序,默认从小到大(ASC),加 DESC 表示从大到小。",
|
||||
boost: 90,
|
||||
apply: "ORDER BY ",
|
||||
},
|
||||
{
|
||||
label: "GROUP BY",
|
||||
detail: "分组",
|
||||
type: "keyword",
|
||||
info: "按指定列分组,常配合 COUNT、SUM 等聚合函数统计每组数据。",
|
||||
boost: 88,
|
||||
apply: "GROUP BY ",
|
||||
},
|
||||
{
|
||||
label: "HAVING",
|
||||
detail: "分组后筛选",
|
||||
type: "keyword",
|
||||
info: "对分组后的结果再筛选,WHERE 筛选行,HAVING 筛选组。",
|
||||
boost: 86,
|
||||
apply: "HAVING ",
|
||||
},
|
||||
{
|
||||
label: "LIMIT",
|
||||
detail: "限制条数",
|
||||
type: "keyword",
|
||||
info: "限制返回的行数,如 LIMIT 5 只取前 5 条,常和 ORDER BY 搭配。",
|
||||
boost: 84,
|
||||
apply: "LIMIT ",
|
||||
},
|
||||
{
|
||||
label: "DISTINCT",
|
||||
detail: "去重",
|
||||
type: "keyword",
|
||||
info: "去掉查询结果中的重复值,如 SELECT DISTINCT city FROM users。",
|
||||
boost: 82,
|
||||
apply: "DISTINCT ",
|
||||
},
|
||||
{
|
||||
label: "AS",
|
||||
detail: "起别名",
|
||||
type: "keyword",
|
||||
info: "给列或表起别名,让结果更易读,如 SELECT name AS 姓名。",
|
||||
boost: 80,
|
||||
apply: "AS ",
|
||||
},
|
||||
{
|
||||
label: "JOIN",
|
||||
detail: "连接表",
|
||||
type: "keyword",
|
||||
info: "把两张表按条件连接起来查询,需要用 ON 指定连接条件。",
|
||||
boost: 78,
|
||||
apply: "JOIN ",
|
||||
},
|
||||
{
|
||||
label: "LEFT JOIN",
|
||||
detail: "左连接",
|
||||
type: "keyword",
|
||||
info: "以左表为主连接右表,左表的行都保留,右表没匹配的补 NULL。",
|
||||
boost: 76,
|
||||
apply: "LEFT JOIN ",
|
||||
},
|
||||
{
|
||||
label: "ON",
|
||||
detail: "连接条件",
|
||||
type: "keyword",
|
||||
info: "指定两张表的连接条件,如 ON a.id = b.user_id,和 JOIN 搭配。",
|
||||
boost: 74,
|
||||
apply: "ON ",
|
||||
},
|
||||
{
|
||||
label: "INSERT INTO",
|
||||
detail: "插入数据",
|
||||
type: "keyword",
|
||||
info: "向表中插入新行,如 INSERT INTO 表名 (列1, 列2) VALUES (值1, 值2)。",
|
||||
boost: 72,
|
||||
apply: "INSERT INTO ",
|
||||
},
|
||||
{
|
||||
label: "VALUES",
|
||||
detail: "插入的值",
|
||||
type: "keyword",
|
||||
info: "和 INSERT INTO 搭配,写具体要插入的值,顺序要和列名对应。",
|
||||
boost: 70,
|
||||
apply: "VALUES ",
|
||||
},
|
||||
{
|
||||
label: "UPDATE",
|
||||
detail: "更新数据",
|
||||
type: "keyword",
|
||||
info: "修改表中已有的数据,配合 SET 设置新值,别忘了加 WHERE 限定范围。",
|
||||
boost: 68,
|
||||
apply: "UPDATE ",
|
||||
},
|
||||
{
|
||||
label: "SET",
|
||||
detail: "设置新值",
|
||||
type: "keyword",
|
||||
info: "和 UPDATE 搭配,指定要修改的列和新值,如 SET score = 90。",
|
||||
boost: 66,
|
||||
apply: "SET ",
|
||||
},
|
||||
{
|
||||
label: "DELETE FROM",
|
||||
detail: "删除数据",
|
||||
type: "keyword",
|
||||
info: "删除表中的行,一定要配合 WHERE 使用,否则会删掉整张表的数据。",
|
||||
boost: 64,
|
||||
apply: "DELETE FROM ",
|
||||
},
|
||||
{
|
||||
label: "CREATE TABLE",
|
||||
detail: "创建表",
|
||||
type: "keyword",
|
||||
info: "新建一张表,需要定义列名和类型,如 CREATE TABLE users (id INTEGER, name TEXT)。",
|
||||
boost: 62,
|
||||
apply: "CREATE TABLE ",
|
||||
},
|
||||
{
|
||||
label: "DROP TABLE",
|
||||
detail: "删除表",
|
||||
type: "keyword",
|
||||
info: "删除整张表(包括结构和数据),操作不可恢复,要谨慎使用。",
|
||||
boost: 60,
|
||||
apply: "DROP TABLE ",
|
||||
},
|
||||
{
|
||||
label: "ALTER TABLE",
|
||||
detail: "修改表结构",
|
||||
type: "keyword",
|
||||
info: "修改已有表的结构,比如添加列:ALTER TABLE 表名 ADD COLUMN 列名 类型。",
|
||||
boost: 58,
|
||||
apply: "ALTER TABLE ",
|
||||
},
|
||||
{
|
||||
label: "AND",
|
||||
detail: "并且",
|
||||
type: "keyword",
|
||||
info: "连接多个条件,全部成立才算满足,如 WHERE age > 18 AND city = '上海'。",
|
||||
boost: 56,
|
||||
apply: "AND ",
|
||||
},
|
||||
{
|
||||
label: "OR",
|
||||
detail: "或者",
|
||||
type: "keyword",
|
||||
info: "连接多个条件,任意一个成立就算满足。",
|
||||
boost: 54,
|
||||
apply: "OR ",
|
||||
},
|
||||
{
|
||||
label: "NOT",
|
||||
detail: "取反",
|
||||
type: "keyword",
|
||||
info: "对条件取反,如 NOT IN、NOT LIKE、IS NOT NULL。",
|
||||
boost: 52,
|
||||
apply: "NOT ",
|
||||
},
|
||||
{
|
||||
label: "IN",
|
||||
detail: "在列表中",
|
||||
type: "keyword",
|
||||
info: "判断值是否在给定列表中,如 WHERE city IN ('北京', '上海')。",
|
||||
boost: 50,
|
||||
apply: "IN ",
|
||||
},
|
||||
{
|
||||
label: "BETWEEN",
|
||||
detail: "在区间内",
|
||||
type: "keyword",
|
||||
info: "判断值是否在某个范围内(包含两端),如 BETWEEN 60 AND 100。",
|
||||
boost: 48,
|
||||
apply: "BETWEEN ",
|
||||
},
|
||||
{
|
||||
label: "LIKE",
|
||||
detail: "模糊匹配",
|
||||
type: "keyword",
|
||||
info: "模糊查询,% 匹配任意多个字符,_ 匹配单个字符,如 LIKE '张%'。",
|
||||
boost: 46,
|
||||
apply: "LIKE ",
|
||||
},
|
||||
{
|
||||
label: "IS NULL",
|
||||
detail: "是否为空",
|
||||
type: "keyword",
|
||||
info: "判断值是否为 NULL(空值),不能写 = NULL,要用 IS NULL。",
|
||||
boost: 44,
|
||||
},
|
||||
{
|
||||
label: "IS NOT NULL",
|
||||
detail: "是否非空",
|
||||
type: "keyword",
|
||||
info: "判断值不为 NULL,常用于过滤掉缺失数据的行。",
|
||||
boost: 42,
|
||||
},
|
||||
{
|
||||
label: "ASC",
|
||||
detail: "升序",
|
||||
type: "keyword",
|
||||
info: "排序时从小到大排列,是 ORDER BY 的默认方式,可以省略。",
|
||||
boost: 40,
|
||||
},
|
||||
{
|
||||
label: "DESC",
|
||||
detail: "降序",
|
||||
type: "keyword",
|
||||
info: "排序时从大到小排列,如 ORDER BY score DESC 按分数从高到低。",
|
||||
boost: 41,
|
||||
},
|
||||
{
|
||||
label: "UNION",
|
||||
detail: "合并结果",
|
||||
type: "keyword",
|
||||
info: "合并两个查询的结果并去重,两个查询的列数和类型要一致。",
|
||||
boost: 38,
|
||||
apply: "UNION ",
|
||||
},
|
||||
{
|
||||
label: "CASE",
|
||||
detail: "条件表达式",
|
||||
type: "keyword",
|
||||
info: "类似 if/else 的条件判断,语法 CASE WHEN 条件 THEN 值 ELSE 值 END。",
|
||||
boost: 36,
|
||||
apply: "CASE ",
|
||||
},
|
||||
{
|
||||
label: "WHEN",
|
||||
detail: "当条件成立",
|
||||
type: "keyword",
|
||||
info: "和 CASE 搭配,写判断条件,成立时返回 THEN 后面的值。",
|
||||
boost: 34,
|
||||
apply: "WHEN ",
|
||||
},
|
||||
{
|
||||
label: "THEN",
|
||||
detail: "返回值",
|
||||
type: "keyword",
|
||||
info: "和 WHEN 搭配,条件成立时返回的结果。",
|
||||
boost: 32,
|
||||
apply: "THEN ",
|
||||
},
|
||||
{
|
||||
label: "ELSE",
|
||||
detail: "否则",
|
||||
type: "keyword",
|
||||
info: "CASE 中所有 WHEN 都不成立时返回的默认值。",
|
||||
boost: 30,
|
||||
apply: "ELSE ",
|
||||
},
|
||||
{
|
||||
label: "END",
|
||||
detail: "结束 CASE",
|
||||
type: "keyword",
|
||||
info: "标记 CASE 表达式的结束,写 CASE 时不要漏掉。",
|
||||
boost: 28,
|
||||
},
|
||||
{
|
||||
label: "NULL",
|
||||
detail: "空值",
|
||||
type: "keyword",
|
||||
info: "表示没有值(缺失),判断时要用 IS NULL / IS NOT NULL。",
|
||||
boost: 26,
|
||||
},
|
||||
{
|
||||
label: "COUNT",
|
||||
detail: "统计行数",
|
||||
type: "function",
|
||||
info: "统计行数,COUNT(*) 统计所有行,COUNT(列名) 不统计 NULL。",
|
||||
boost: 92,
|
||||
apply: "COUNT()",
|
||||
},
|
||||
{
|
||||
label: "SUM",
|
||||
detail: "求和",
|
||||
type: "function",
|
||||
info: "对某一列求和,只能用于数字列,如 SUM(score)。",
|
||||
boost: 87,
|
||||
apply: "SUM()",
|
||||
},
|
||||
{
|
||||
label: "AVG",
|
||||
detail: "平均值",
|
||||
type: "function",
|
||||
info: "计算某一列的平均值,会自动忽略 NULL,如 AVG(score)。",
|
||||
boost: 85,
|
||||
apply: "AVG()",
|
||||
},
|
||||
{
|
||||
label: "MAX",
|
||||
detail: "最大值",
|
||||
type: "function",
|
||||
info: "求某一列的最大值,如 MAX(score) 找最高分。",
|
||||
boost: 83,
|
||||
apply: "MAX()",
|
||||
},
|
||||
{
|
||||
label: "MIN",
|
||||
detail: "最小值",
|
||||
type: "function",
|
||||
info: "求某一列的最小值,如 MIN(score) 找最低分。",
|
||||
boost: 81,
|
||||
apply: "MIN()",
|
||||
},
|
||||
{
|
||||
label: "LENGTH",
|
||||
detail: "字符串长度",
|
||||
type: "function",
|
||||
info: "返回字符串的字符个数,如 LENGTH(name)。",
|
||||
boost: 79,
|
||||
apply: "LENGTH()",
|
||||
},
|
||||
{
|
||||
label: "UPPER",
|
||||
detail: "转大写",
|
||||
type: "function",
|
||||
info: "把字符串中的字母转成大写,如 UPPER('abc') 得到 'ABC'。",
|
||||
boost: 77,
|
||||
apply: "UPPER()",
|
||||
},
|
||||
{
|
||||
label: "LOWER",
|
||||
detail: "转小写",
|
||||
type: "function",
|
||||
info: "把字符串中的字母转成小写,如 LOWER('ABC') 得到 'abc'。",
|
||||
boost: 75,
|
||||
apply: "LOWER()",
|
||||
},
|
||||
{
|
||||
label: "SUBSTR",
|
||||
detail: "截取子串",
|
||||
type: "function",
|
||||
info: "截取字符串的一部分,语法 SUBSTR(字符串, 起始位置, 长度),位置从 1 开始。",
|
||||
boost: 73,
|
||||
apply: "SUBSTR()",
|
||||
},
|
||||
{
|
||||
label: "REPLACE",
|
||||
detail: "替换字符串",
|
||||
type: "function",
|
||||
info: "把字符串中的内容替换成新内容,语法 REPLACE(字符串, 旧内容, 新内容)。",
|
||||
boost: 71,
|
||||
apply: "REPLACE()",
|
||||
},
|
||||
{
|
||||
label: "ROUND",
|
||||
detail: "四舍五入",
|
||||
type: "function",
|
||||
info: "按指定小数位四舍五入,如 ROUND(3.456, 2) 得到 3.46。",
|
||||
boost: 69,
|
||||
apply: "ROUND()",
|
||||
},
|
||||
{
|
||||
label: "ABS",
|
||||
detail: "绝对值",
|
||||
type: "function",
|
||||
info: "返回数字的绝对值,把负数变成正数,如 ABS(-5) 得到 5。",
|
||||
boost: 67,
|
||||
apply: "ABS()",
|
||||
},
|
||||
{
|
||||
label: "IFNULL",
|
||||
detail: "空值替代",
|
||||
type: "function",
|
||||
info: "如果第一个值是 NULL 就返回第二个值,如 IFNULL(score, 0) 把空分数当 0。",
|
||||
boost: 65,
|
||||
apply: "IFNULL()",
|
||||
},
|
||||
{
|
||||
label: "TRIM",
|
||||
detail: "去首尾空格",
|
||||
type: "function",
|
||||
info: "去掉字符串首尾的空格,常用于清理输入数据。",
|
||||
boost: 63,
|
||||
apply: "TRIM()",
|
||||
},
|
||||
]
|
||||
204
apps/web/src/shared/layout/admin.vue
Normal file
204
apps/web/src/shared/layout/admin.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import { RouterLink } from "vue-router"
|
||||
import { STORAGE_KEY } from "utils/constants"
|
||||
import storage from "utils/storage"
|
||||
import { useUserStore } from "../store/user"
|
||||
import type { MenuOption } from "naive-ui"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 根据用户权限动态生成菜单选项
|
||||
const options = computed<MenuOption[]>(() => {
|
||||
const baseOptions: MenuOption[] = [
|
||||
{
|
||||
label: () => h(RouterLink, { to: "/" }, { default: () => "前台" }),
|
||||
key: "return to OJ",
|
||||
},
|
||||
]
|
||||
|
||||
// Student Admin: only problems
|
||||
if (userStore.isStudentAdmin) {
|
||||
baseOptions.push({
|
||||
label: () =>
|
||||
h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }),
|
||||
key: "admin problem list",
|
||||
})
|
||||
}
|
||||
|
||||
// Teacher Admin: problems + contests + problemsets
|
||||
if (userStore.isTeacherAdmin) {
|
||||
baseOptions.push(
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/problem/list" },
|
||||
{ default: () => "题目" },
|
||||
),
|
||||
key: "admin problem list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/contest/list" },
|
||||
{ default: () => "比赛" },
|
||||
),
|
||||
key: "admin contest list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/problemset/list" },
|
||||
{ default: () => "题单" },
|
||||
),
|
||||
key: "admin problemset list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/ai/reports" },
|
||||
{ default: () => "AI报告" },
|
||||
),
|
||||
key: "admin ai reports",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Super Admin: everything
|
||||
if (userStore.isSuperAdmin) {
|
||||
baseOptions.push(
|
||||
{
|
||||
label: () => h(RouterLink, { to: "/admin" }, { default: () => "管理" }),
|
||||
key: "admin home",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: "/admin/config" }, { default: () => "设置" }),
|
||||
key: "admin config",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/problem/list" },
|
||||
{ default: () => "题目" },
|
||||
),
|
||||
key: "admin problem list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/contest/list" },
|
||||
{ default: () => "比赛" },
|
||||
),
|
||||
key: "admin contest list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/problemset/list" },
|
||||
{ default: () => "题单" },
|
||||
),
|
||||
key: "admin problemset list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }),
|
||||
key: "admin user list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/announcement/list" },
|
||||
{ default: () => "公告" },
|
||||
),
|
||||
key: "admin announcement list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/tutorial/list" },
|
||||
{ default: () => "教程" },
|
||||
),
|
||||
key: "admin tutorial list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/achievement/list" },
|
||||
{ default: () => "成就" },
|
||||
),
|
||||
key: "admin achievement list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/ai/reports" },
|
||||
{ default: () => "AI报告" },
|
||||
),
|
||||
key: "admin ai reports",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return baseOptions
|
||||
})
|
||||
|
||||
// 根据路径计算当前激活的菜单项
|
||||
const active = computed(() => {
|
||||
const path = route.path
|
||||
if (path === "/") return "return to OJ"
|
||||
if (path === "/admin") return "admin home"
|
||||
if (path.startsWith("/admin/config")) return "admin config"
|
||||
if (path.startsWith("/admin/problemset")) return "admin problemset list"
|
||||
if (path.startsWith("/admin/problem/stuck")) return "admin stuck problems"
|
||||
if (path.startsWith("/admin/problem/tags")) return "admin problem list"
|
||||
if (path.startsWith("/admin/problem")) return "admin problem list"
|
||||
if (path.startsWith("/admin/contest")) return "admin contest list"
|
||||
if (path.startsWith("/admin/user")) return "admin user list"
|
||||
if (path.startsWith("/admin/announcement")) return "admin announcement list"
|
||||
if (path.startsWith("/admin/tutorial")) return "admin tutorial list"
|
||||
if (path.startsWith("/admin/ai")) return "admin ai reports"
|
||||
return route.name as string
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
||||
router.replace("/")
|
||||
} else {
|
||||
await userStore.getMyProfile()
|
||||
if (!userStore.isAdminRole) {
|
||||
router.replace("/")
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-layout has-sider position="absolute">
|
||||
<!-- 侧边栏 -->
|
||||
<n-layout-sider bordered :width="100" :native-scrollbar="false">
|
||||
<!-- 菜单 -->
|
||||
<n-menu :options="options" :value="active" />
|
||||
</n-layout-sider>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<n-layout>
|
||||
<n-layout-content content-style="padding: 20px">
|
||||
<router-view></router-view>
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
</template>
|
||||
50
apps/web/src/shared/layout/default.vue
Normal file
50
apps/web/src/shared/layout/default.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import Beian from "../components/Beian.vue"
|
||||
import Header from "../components/Header.vue"
|
||||
import Login from "../components/Login.vue"
|
||||
import Signup from "../components/Signup.vue"
|
||||
import LoginSummaryModal from "../components/LoginSummaryModal.vue"
|
||||
import AchievementToast from "../components/AchievementToast.vue"
|
||||
import { useAchievementStore } from "shared/store/achievement"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
const achievementStore = useAchievementStore()
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
|
||||
// 拉取才是主通道:WebSocket 不是常驻连接(只在问题页且有提交监听时建连),
|
||||
// 所以任何页面、任何时刻解锁的成就都靠这里补上
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
if (userStore.isAuthed) achievementStore.fetchPending()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-layout position="absolute">
|
||||
<n-layout-header bordered style="padding: 8px">
|
||||
<Header class="header" />
|
||||
</n-layout-header>
|
||||
<n-layout-content
|
||||
content-style="padding: 16px; overflow-x: initial; max-width: 2000px; margin: 0 auto;"
|
||||
>
|
||||
<router-view></router-view>
|
||||
</n-layout-content>
|
||||
<Login />
|
||||
<Signup />
|
||||
<LoginSummaryModal />
|
||||
<AchievementToast />
|
||||
<Beian />
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
max-width: 2000px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
64
apps/web/src/shared/store/achievement.ts
Normal file
64
apps/web/src/shared/store/achievement.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
getPendingAchievements,
|
||||
markAchievementsRead,
|
||||
} from "oj/achievement/api"
|
||||
import type { PendingAchievement } from "utils/types"
|
||||
|
||||
/**
|
||||
* 成就解锁弹窗队列。
|
||||
*
|
||||
* 通知走推拉结合,后端的 UserAchievement.notified 是唯一真相来源:
|
||||
* - 拉(主):布局层每次路由切换拉一次 pending,覆盖全部场景,绝不丢
|
||||
* - 推(增强):WebSocket 只在用户当场停留在问题页时把延迟压到几百毫秒
|
||||
*
|
||||
* 之所以不能只靠推:前端 WebSocket 不是常驻连接,只在问题页且有提交监听时
|
||||
* 才建连,纯推会丢消息(尤其是题单奖章,那些页面根本没建连接)。
|
||||
*/
|
||||
export const useAchievementStore = defineStore("achievement", () => {
|
||||
const queue = ref<PendingAchievement[]>([])
|
||||
const current = ref<PendingAchievement | null>(null)
|
||||
|
||||
// 成就和题单奖章的 id 来自两张不同的表,数值会重叠,
|
||||
// 只按 id 去重会让奖章 5 把成就 5 挤掉
|
||||
function keyOf(item: PendingAchievement) {
|
||||
return `${item.kind ?? "achievement"}:${item.id}`
|
||||
}
|
||||
|
||||
function enqueue(items: PendingAchievement[]) {
|
||||
if (!items?.length) return
|
||||
// 去重:WebSocket 推来的和 pending 拉来的可能是同一批
|
||||
const known = new Set([
|
||||
...queue.value.map(keyOf),
|
||||
...(current.value ? [keyOf(current.value)] : []),
|
||||
])
|
||||
queue.value.push(...items.filter((i) => !known.has(keyOf(i))))
|
||||
}
|
||||
|
||||
async function fetchPending() {
|
||||
try {
|
||||
// http 客户端返回 ApiResponse<T>,真实载荷在 .data 里
|
||||
const res = await getPendingAchievements()
|
||||
enqueue(res.data ?? [])
|
||||
} catch {
|
||||
// 拉取失败静默处理,下次路由切换会再拉
|
||||
}
|
||||
}
|
||||
|
||||
function next() {
|
||||
current.value = queue.value.shift() ?? null
|
||||
return current.value
|
||||
}
|
||||
|
||||
async function markRead(item: PendingAchievement) {
|
||||
// 奖章不在 UserAchievement 表里,它的 id 传给标记接口会被当成成就 id,
|
||||
// 把一个恰好同号、还没弹过的成就静默标记为已弹——那个奖杯就再也不会出现
|
||||
if (item.kind === "badge") return
|
||||
try {
|
||||
await markAchievementsRead([item.id])
|
||||
} catch {
|
||||
// 标记失败下次会重复弹一次,可接受
|
||||
}
|
||||
}
|
||||
|
||||
return { queue, current, enqueue, fetchPending, next, markRead }
|
||||
})
|
||||
155
apps/web/src/shared/store/authModal.ts
Normal file
155
apps/web/src/shared/store/authModal.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { defineStore } from "pinia"
|
||||
|
||||
/**
|
||||
* 认证状态管理 Store
|
||||
* 统一管理登录、注册相关的模态框状态和表单状态
|
||||
*/
|
||||
export const useAuthModalStore = defineStore("authModal", () => {
|
||||
// ==================== 模态框状态 ====================
|
||||
const loginModalOpen = ref(false)
|
||||
const signupModalOpen = ref(false)
|
||||
|
||||
// ==================== 登录表单状态 ====================
|
||||
const loginForm = reactive({
|
||||
class: "",
|
||||
username: "",
|
||||
password: "",
|
||||
})
|
||||
|
||||
const loginLoading = ref(false)
|
||||
const loginError = ref("")
|
||||
|
||||
// ==================== 注册表单状态 ====================
|
||||
const signupForm = reactive({
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
passwordAgain: "",
|
||||
})
|
||||
|
||||
const signupLoading = ref(false)
|
||||
const signupError = ref("")
|
||||
|
||||
// ==================== 模态框操作 ====================
|
||||
/**
|
||||
* 打开登录模态框
|
||||
*/
|
||||
function openLoginModal() {
|
||||
loginModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭登录模态框
|
||||
*/
|
||||
function closeLoginModal() {
|
||||
loginModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开注册模态框
|
||||
*/
|
||||
function openSignupModal() {
|
||||
signupModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭注册模态框
|
||||
*/
|
||||
function closeSignupModal() {
|
||||
signupModalOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 从登录切换到注册
|
||||
*/
|
||||
function switchToSignup() {
|
||||
closeLoginModal()
|
||||
openSignupModal()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从注册切换到登录
|
||||
*/
|
||||
function switchToLogin() {
|
||||
closeSignupModal()
|
||||
openLoginModal()
|
||||
}
|
||||
|
||||
// ==================== 登录表单操作 ====================
|
||||
/**
|
||||
* 设置登录加载状态
|
||||
*/
|
||||
function setLoginLoading(loading: boolean) {
|
||||
loginLoading.value = loading
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置登录错误信息
|
||||
*/
|
||||
function setLoginError(error: string) {
|
||||
loginError.value = error
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空登录错误
|
||||
*/
|
||||
function clearLoginError() {
|
||||
loginError.value = ""
|
||||
}
|
||||
|
||||
// ==================== 注册表单操作 ====================
|
||||
/**
|
||||
* 设置注册加载状态
|
||||
*/
|
||||
function setSignupLoading(loading: boolean) {
|
||||
signupLoading.value = loading
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置注册错误信息
|
||||
*/
|
||||
function setSignupError(error: string) {
|
||||
signupError.value = error
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空注册错误
|
||||
*/
|
||||
function clearSignupError() {
|
||||
signupError.value = ""
|
||||
}
|
||||
|
||||
return {
|
||||
// 模态框状态
|
||||
loginModalOpen,
|
||||
signupModalOpen,
|
||||
|
||||
// 登录表单状态
|
||||
loginForm,
|
||||
loginLoading,
|
||||
loginError,
|
||||
|
||||
// 注册表单状态
|
||||
signupForm,
|
||||
signupLoading,
|
||||
signupError,
|
||||
|
||||
// 模态框操作
|
||||
openLoginModal,
|
||||
closeLoginModal,
|
||||
openSignupModal,
|
||||
closeSignupModal,
|
||||
switchToSignup,
|
||||
switchToLogin,
|
||||
|
||||
// 登录表单操作
|
||||
setLoginLoading,
|
||||
setLoginError,
|
||||
clearLoginError,
|
||||
|
||||
// 注册表单操作
|
||||
setSignupLoading,
|
||||
setSignupError,
|
||||
clearSignupError,
|
||||
}
|
||||
})
|
||||
24
apps/web/src/shared/store/config.ts
Normal file
24
apps/web/src/shared/store/config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getWebsiteConfig } from "oj/api"
|
||||
import type { WebsiteConfig } from "utils/types"
|
||||
|
||||
export const useConfigStore = defineStore("config", () => {
|
||||
const config = ref<WebsiteConfig>({
|
||||
website_base_url: "",
|
||||
website_name: "",
|
||||
website_name_shortcut: "",
|
||||
website_footer: "",
|
||||
submission_list_show_all: true,
|
||||
allow_register: false,
|
||||
class_list: [],
|
||||
enable_maxkb: true,
|
||||
})
|
||||
async function getConfig() {
|
||||
const res = await getWebsiteConfig()
|
||||
config.value = res.data
|
||||
document.title = res.data.website_name
|
||||
}
|
||||
return {
|
||||
config,
|
||||
getConfig,
|
||||
}
|
||||
})
|
||||
74
apps/web/src/shared/store/loginSummary.ts
Normal file
74
apps/web/src/shared/store/loginSummary.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { getAILoginSummary } from "oj/api"
|
||||
|
||||
interface LoginSummary {
|
||||
start: string
|
||||
end: string
|
||||
new_problem_count: number
|
||||
submission_count: number
|
||||
accepted_count: number
|
||||
solved_count: number
|
||||
flowchart_submission_count: number
|
||||
}
|
||||
|
||||
export const useLoginSummaryStore = defineStore("loginSummary", () => {
|
||||
const show = ref(false)
|
||||
const loading = ref(false)
|
||||
const summary = ref<LoginSummary | null>(null)
|
||||
const analysis = ref("")
|
||||
const analysisError = ref("")
|
||||
|
||||
function shouldShowSummary(nextSummary: LoginSummary | null) {
|
||||
if (!nextSummary) {
|
||||
return false
|
||||
}
|
||||
const values = [
|
||||
nextSummary.new_problem_count,
|
||||
nextSummary.submission_count,
|
||||
nextSummary.accepted_count,
|
||||
nextSummary.solved_count,
|
||||
nextSummary.flowchart_submission_count,
|
||||
]
|
||||
const zeroCount = values.filter((value) => value === 0).length
|
||||
return zeroCount < Math.floor(values.length / 2) + 1
|
||||
}
|
||||
|
||||
async function fetchSummary() {
|
||||
loading.value = true
|
||||
analysis.value = ""
|
||||
analysisError.value = ""
|
||||
try {
|
||||
const res = await getAILoginSummary()
|
||||
summary.value = res.data.summary
|
||||
analysis.value = res.data.analysis || ""
|
||||
analysisError.value = res.data.analysis_error || ""
|
||||
} catch (err) {
|
||||
analysisError.value = "获取登录统计失败,请稍后再试"
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function open() {
|
||||
await fetchSummary()
|
||||
if (!summary.value && analysisError.value) {
|
||||
show.value = true
|
||||
return
|
||||
}
|
||||
show.value = shouldShowSummary(summary.value)
|
||||
}
|
||||
|
||||
function close() {
|
||||
show.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
loading,
|
||||
summary,
|
||||
analysis,
|
||||
analysisError,
|
||||
fetchSummary,
|
||||
open,
|
||||
close,
|
||||
}
|
||||
})
|
||||
18
apps/web/src/shared/store/myFlowchart.ts
Normal file
18
apps/web/src/shared/store/myFlowchart.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineStore } from "pinia"
|
||||
|
||||
export const useMyFlowchartStore = defineStore("myFlowchart", () => {
|
||||
const showing = ref(false)
|
||||
const mermaidCode = ref("")
|
||||
|
||||
function show(code: string) {
|
||||
mermaidCode.value = code
|
||||
showing.value = true
|
||||
}
|
||||
|
||||
function hide() {
|
||||
showing.value = false
|
||||
mermaidCode.value = ""
|
||||
}
|
||||
|
||||
return { showing, mermaidCode, show, hide }
|
||||
})
|
||||
34
apps/web/src/shared/store/screenMode.ts
Normal file
34
apps/web/src/shared/store/screenMode.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { ScreenMode } from "utils/constants"
|
||||
|
||||
export const useScreenModeStore = defineStore("screenMode", () => {
|
||||
const { state: screenMode, next: switchScreenMode } = useCycleList(
|
||||
Object.values(ScreenMode),
|
||||
{
|
||||
initialValue: ScreenMode.both,
|
||||
},
|
||||
)
|
||||
|
||||
// 计算属性
|
||||
const isBothMode = computed(() => screenMode.value === ScreenMode.both)
|
||||
const isCodeOnlyMode = computed(() => screenMode.value === ScreenMode.code)
|
||||
|
||||
const shouldShowProblem = computed(
|
||||
() =>
|
||||
screenMode.value === ScreenMode.both ||
|
||||
screenMode.value === ScreenMode.problem,
|
||||
)
|
||||
|
||||
function resetScreenMode() {
|
||||
screenMode.value = ScreenMode.both
|
||||
}
|
||||
|
||||
return {
|
||||
screenMode,
|
||||
isBothMode,
|
||||
isCodeOnlyMode,
|
||||
shouldShowProblem,
|
||||
switchScreenMode,
|
||||
resetScreenMode,
|
||||
}
|
||||
})
|
||||
94
apps/web/src/shared/store/user.ts
Normal file
94
apps/web/src/shared/store/user.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { PROBLEM_PERMISSION, STORAGE_KEY, USER_TYPE } from "utils/constants"
|
||||
import storage from "utils/storage"
|
||||
import type { Profile, User } from "utils/types"
|
||||
import { getProfile } from "../api"
|
||||
import { useConfigStore } from "./config"
|
||||
|
||||
export const useUserStore = defineStore("user", () => {
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const profile = ref<Profile | null>(null)
|
||||
const [isFinished] = useToggle(false)
|
||||
const user = computed<User | null>(() => profile.value?.user ?? null)
|
||||
const isAuthed = computed(() => !!user.value?.email)
|
||||
|
||||
// 演示模式:超管临时把界面伪装成普通学生,方便上课投屏
|
||||
const demoMode = ref<boolean>(storage.get(STORAGE_KEY.DEMO_MODE) ?? false)
|
||||
|
||||
// 不受伪装影响的真实身份,只用于判断能否切换演示模式。
|
||||
// 若这里用被伪装后的 isSuperAdmin,一进入演示模式入口就消失了,退不出来。
|
||||
const realIsSuperAdmin = computed(
|
||||
() => user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
|
||||
)
|
||||
|
||||
const isAdminRole = computed(
|
||||
() =>
|
||||
!demoMode.value &&
|
||||
(user.value?.admin_type === USER_TYPE.STUDENT_ADMIN ||
|
||||
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
|
||||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN),
|
||||
)
|
||||
const isStudentAdmin = computed(
|
||||
() => !demoMode.value && user.value?.admin_type === USER_TYPE.STUDENT_ADMIN,
|
||||
)
|
||||
const isTeacherAdmin = computed(
|
||||
() => !demoMode.value && user.value?.admin_type === USER_TYPE.TEACHER_ADMIN,
|
||||
)
|
||||
const isTeacherOrAbove = computed(
|
||||
() =>
|
||||
!demoMode.value &&
|
||||
(user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
|
||||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN),
|
||||
)
|
||||
const isSuperAdmin = computed(() => !demoMode.value && realIsSuperAdmin.value)
|
||||
const hasProblemPermission = computed(
|
||||
() =>
|
||||
!demoMode.value &&
|
||||
user.value?.problem_permission !== PROBLEM_PERMISSION.NONE,
|
||||
)
|
||||
|
||||
const canToggleDemoMode = computed(() => realIsSuperAdmin.value)
|
||||
|
||||
function toggleDemoMode() {
|
||||
demoMode.value = !demoMode.value
|
||||
storage.set(STORAGE_KEY.DEMO_MODE, demoMode.value)
|
||||
}
|
||||
|
||||
const showSubmissions = computed(() => {
|
||||
let flag = configStore.config.submission_list_show_all
|
||||
if (isAdminRole.value) flag = true
|
||||
return flag
|
||||
})
|
||||
|
||||
async function getMyProfile() {
|
||||
isFinished.value = false
|
||||
const res = await getProfile()
|
||||
profile.value = res.data
|
||||
isFinished.value = true
|
||||
storage.set(STORAGE_KEY.AUTHED, !!user.value?.email)
|
||||
}
|
||||
|
||||
function clearProfile() {
|
||||
profile.value = null
|
||||
demoMode.value = false
|
||||
storage.clear()
|
||||
}
|
||||
return {
|
||||
profile,
|
||||
isFinished,
|
||||
user,
|
||||
isAdminRole,
|
||||
isStudentAdmin,
|
||||
isTeacherAdmin,
|
||||
isTeacherOrAbove,
|
||||
isSuperAdmin,
|
||||
hasProblemPermission,
|
||||
demoMode,
|
||||
canToggleDemoMode,
|
||||
toggleDemoMode,
|
||||
isAuthed,
|
||||
showSubmissions,
|
||||
getMyProfile,
|
||||
clearProfile,
|
||||
}
|
||||
})
|
||||
110
apps/web/src/shared/themes/createTheme.ts
Normal file
110
apps/web/src/shared/themes/createTheme.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
HighlightStyle,
|
||||
TagStyle,
|
||||
syntaxHighlighting,
|
||||
} from "@codemirror/language"
|
||||
import { Extension } from "@codemirror/state"
|
||||
import { EditorView } from "@codemirror/view"
|
||||
|
||||
interface Options {
|
||||
/**
|
||||
* Theme variant. Determines which styles CodeMirror will apply by default.
|
||||
*/
|
||||
variant: Variant
|
||||
|
||||
/**
|
||||
* Settings to customize the look of the editor, like background, gutter, selection and others.
|
||||
*/
|
||||
settings: Settings
|
||||
|
||||
/**
|
||||
* Syntax highlighting styles.
|
||||
*/
|
||||
styles: TagStyle[]
|
||||
}
|
||||
|
||||
type Variant = "light" | "dark"
|
||||
|
||||
interface Settings {
|
||||
/**
|
||||
* Editor background.
|
||||
*/
|
||||
background: string
|
||||
|
||||
/**
|
||||
* Default text color.
|
||||
*/
|
||||
foreground: string
|
||||
|
||||
/**
|
||||
* Caret color.
|
||||
*/
|
||||
caret: string
|
||||
|
||||
/**
|
||||
* Selection background.
|
||||
*/
|
||||
selection: string
|
||||
|
||||
/**
|
||||
* Background of highlighted lines.
|
||||
*/
|
||||
lineHighlight: string
|
||||
|
||||
/**
|
||||
* Gutter background.
|
||||
*/
|
||||
gutterBackground: string
|
||||
|
||||
/**
|
||||
* Text color inside gutter.
|
||||
*/
|
||||
gutterForeground: string
|
||||
|
||||
gutterBorderRight: string
|
||||
}
|
||||
|
||||
export const createTheme = ({
|
||||
variant,
|
||||
settings,
|
||||
styles,
|
||||
}: Options): Extension => {
|
||||
const theme = EditorView.theme(
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
"&": {
|
||||
backgroundColor: settings.background,
|
||||
color: settings.foreground,
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: settings.caret,
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: settings.caret,
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
|
||||
{
|
||||
backgroundColor: settings.selection,
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: settings.lineHighlight,
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: settings.gutterBackground,
|
||||
borderRight: settings.gutterBorderRight,
|
||||
color: settings.gutterForeground,
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: settings.lineHighlight,
|
||||
},
|
||||
},
|
||||
{
|
||||
dark: variant === "dark",
|
||||
},
|
||||
)
|
||||
|
||||
const highlightStyle = HighlightStyle.define(styles)
|
||||
const extension = [theme, syntaxHighlighting(highlightStyle)]
|
||||
|
||||
return extension
|
||||
}
|
||||
149
apps/web/src/shared/themes/oneDark.ts
Normal file
149
apps/web/src/shared/themes/oneDark.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"
|
||||
import { Extension } from "@codemirror/state"
|
||||
import { EditorView } from "@codemirror/view"
|
||||
import { tags as t } from "@lezer/highlight"
|
||||
|
||||
// Using https://github.com/one-dark/vscode-one-dark-theme/ as reference for the colors
|
||||
|
||||
const chalky = "#e5c07b",
|
||||
coral = "#e06c75",
|
||||
cyan = "#56b6c2",
|
||||
invalid = "#ffffff",
|
||||
ivory = "#abb2bf",
|
||||
stone = "#7d8799", // Brightened compared to original to increase contrast
|
||||
malibu = "#61afef",
|
||||
sage = "#98c379",
|
||||
whiskey = "#d19a66",
|
||||
violet = "#c678dd",
|
||||
darkBackground = "#26262a",
|
||||
highlightBackground = "#2c313a",
|
||||
background = "#101014", // naive-ui
|
||||
tooltipBackground = "#353a42",
|
||||
selection = "#3E4451",
|
||||
cursor = "#528bff"
|
||||
|
||||
/// The editor theme styles for One Dark.
|
||||
const oneDarkTheme = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
color: ivory,
|
||||
backgroundColor: background,
|
||||
},
|
||||
|
||||
".cm-content": {
|
||||
caretColor: cursor,
|
||||
},
|
||||
|
||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: cursor },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
|
||||
{ backgroundColor: selection },
|
||||
|
||||
".cm-panels": { backgroundColor: darkBackground, color: ivory },
|
||||
".cm-panels.cm-panels-top": { borderBottom: "2px solid black" },
|
||||
".cm-panels.cm-panels-bottom": { borderTop: "2px solid black" },
|
||||
|
||||
".cm-searchMatch": {
|
||||
backgroundColor: "#72a1ff59",
|
||||
outline: "1px solid #457dff",
|
||||
},
|
||||
".cm-searchMatch.cm-searchMatch-selected": {
|
||||
backgroundColor: "#6199ff2f",
|
||||
},
|
||||
|
||||
".cm-activeLine": { backgroundColor: "#6699ff0b" },
|
||||
".cm-selectionMatch": { backgroundColor: "#aafe661a" },
|
||||
|
||||
"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": {
|
||||
backgroundColor: "#bad0f847",
|
||||
},
|
||||
|
||||
".cm-gutters": {
|
||||
backgroundColor: background,
|
||||
color: stone,
|
||||
border: "none",
|
||||
},
|
||||
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: highlightBackground,
|
||||
},
|
||||
|
||||
".cm-foldPlaceholder": {
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
color: "#ddd",
|
||||
},
|
||||
|
||||
".cm-tooltip": {
|
||||
border: "none",
|
||||
backgroundColor: tooltipBackground,
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:before": {
|
||||
borderTopColor: "transparent",
|
||||
borderBottomColor: "transparent",
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:after": {
|
||||
borderTopColor: tooltipBackground,
|
||||
borderBottomColor: tooltipBackground,
|
||||
},
|
||||
".cm-tooltip-autocomplete": {
|
||||
"& > ul > li[aria-selected]": {
|
||||
backgroundColor: highlightBackground,
|
||||
color: ivory,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ dark: true },
|
||||
)
|
||||
|
||||
/// The highlighting style for code in the One Dark theme.
|
||||
const oneDarkHighlightStyle = HighlightStyle.define([
|
||||
{ tag: t.keyword, color: violet },
|
||||
{
|
||||
tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName],
|
||||
color: coral,
|
||||
},
|
||||
{ tag: [t.function(t.variableName), t.labelName], color: malibu },
|
||||
{ tag: [t.color, t.constant(t.name), t.standard(t.name)], color: whiskey },
|
||||
{ tag: [t.definition(t.name), t.separator], color: ivory },
|
||||
{
|
||||
tag: [
|
||||
t.typeName,
|
||||
t.className,
|
||||
t.number,
|
||||
t.changed,
|
||||
t.annotation,
|
||||
t.modifier,
|
||||
t.self,
|
||||
t.namespace,
|
||||
],
|
||||
color: chalky,
|
||||
},
|
||||
{
|
||||
tag: [
|
||||
t.operator,
|
||||
t.operatorKeyword,
|
||||
t.url,
|
||||
t.escape,
|
||||
t.regexp,
|
||||
t.link,
|
||||
t.special(t.string),
|
||||
],
|
||||
color: cyan,
|
||||
},
|
||||
{ tag: [t.meta, t.comment], color: stone },
|
||||
{ tag: t.strong, fontWeight: "bold" },
|
||||
{ tag: t.emphasis, fontStyle: "italic" },
|
||||
{ tag: t.strikethrough, textDecoration: "line-through" },
|
||||
{ tag: t.link, color: stone, textDecoration: "underline" },
|
||||
{ tag: t.heading, fontWeight: "bold", color: coral },
|
||||
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: whiskey },
|
||||
{ tag: [t.processingInstruction, t.string, t.inserted], color: sage },
|
||||
{ tag: t.invalid, color: invalid },
|
||||
])
|
||||
|
||||
/// Extension to enable the One Dark theme (both the editor theme and
|
||||
/// the highlight style).
|
||||
export const oneDark: Extension = [
|
||||
oneDarkTheme,
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
]
|
||||
100
apps/web/src/shared/themes/smoothy.ts
Normal file
100
apps/web/src/shared/themes/smoothy.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { tags as t } from "@lezer/highlight"
|
||||
import { EditorView } from "@codemirror/view"
|
||||
import type { Extension } from "@codemirror/state"
|
||||
import { createTheme } from "./createTheme"
|
||||
|
||||
const tooltipTheme = EditorView.theme({
|
||||
".cm-tooltip": {
|
||||
border: "1px solid #e0e0e0",
|
||||
backgroundColor: "#f8f8f8",
|
||||
},
|
||||
".cm-tooltip-autocomplete": {
|
||||
"& > ul > li[aria-selected]": {
|
||||
backgroundColor: "#e8e8e8",
|
||||
color: "#000000",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Author: Kenneth Reitz
|
||||
const smoothyBase = createTheme({
|
||||
variant: "light",
|
||||
settings: {
|
||||
background: "#FFFFFF",
|
||||
foreground: "#000000",
|
||||
caret: "#000000",
|
||||
selection: "#FFFD0054",
|
||||
gutterBackground: "#FFFFFF",
|
||||
gutterForeground: "#00000070",
|
||||
gutterBorderRight: "none",
|
||||
lineHighlight: "#00000008",
|
||||
},
|
||||
styles: [
|
||||
{
|
||||
tag: t.comment,
|
||||
color: "#CFCFCF",
|
||||
},
|
||||
{
|
||||
tag: [t.number, t.bool, t.null],
|
||||
color: "#E66C29",
|
||||
},
|
||||
{
|
||||
tag: [
|
||||
t.className,
|
||||
t.definition(t.propertyName),
|
||||
t.function(t.variableName),
|
||||
t.labelName,
|
||||
t.definition(t.typeName),
|
||||
],
|
||||
color: "#2EB43B",
|
||||
},
|
||||
{
|
||||
tag: t.keyword,
|
||||
color: "#D8B229",
|
||||
},
|
||||
{
|
||||
tag: t.operator,
|
||||
color: "#4EA44E",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
tag: [t.definitionKeyword, t.modifier],
|
||||
color: "#925A47",
|
||||
},
|
||||
{
|
||||
tag: t.string,
|
||||
color: "#704D3D",
|
||||
},
|
||||
{
|
||||
tag: t.typeName,
|
||||
color: "#2F8996",
|
||||
},
|
||||
{
|
||||
tag: [t.variableName, t.propertyName],
|
||||
color: "#77ACB0",
|
||||
},
|
||||
{
|
||||
tag: t.self,
|
||||
color: "#77ACB0",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
{
|
||||
tag: t.regexp,
|
||||
color: "#E3965E",
|
||||
},
|
||||
{
|
||||
tag: [t.tagName, t.angleBracket],
|
||||
color: "#BAA827",
|
||||
},
|
||||
{
|
||||
tag: t.attributeName,
|
||||
color: "#B06520",
|
||||
},
|
||||
{
|
||||
tag: t.derefOperator,
|
||||
color: "#000",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export const smoothy: Extension = [smoothyBase, tooltipTheme]
|
||||
Reference in New Issue
Block a user