feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码
This commit is contained in:
69
apps/web/src/oj/problem/components/ContestEditor.vue
Normal file
69
apps/web/src/oj/problem/components/ContestEditor.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { SOURCES } from "utils/constants"
|
||||
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { provideSyncStatus } from "oj/composables/syncStatus"
|
||||
import storage from "utils/storage"
|
||||
import type { LANGUAGE } from "utils/types"
|
||||
import Form from "./Form.vue"
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const codeStore = useCodeStore()
|
||||
const problemStore = useProblemStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
// 提供空的同步状态,避免 Form 组件注入错误
|
||||
// 在竞赛模式下,同步功能会被 showSyncFeature 自动禁用
|
||||
provideSyncStatus()
|
||||
|
||||
const contestID = route.params.contestID || null
|
||||
const storageKey = computed(
|
||||
() =>
|
||||
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
)
|
||||
|
||||
const editorHeight = computed(() =>
|
||||
isDesktop.value ? "calc(100vh - 133px)" : "calc(100vh - 172px)",
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
const savedCode = storage.get(storageKey.value)
|
||||
codeStore.setCode(
|
||||
savedCode ||
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
)
|
||||
})
|
||||
|
||||
const changeCode = (v: string) => {
|
||||
storage.set(storageKey.value, v)
|
||||
}
|
||||
|
||||
const changeLanguage = (v: LANGUAGE) => {
|
||||
const savedCode = storage.get(storageKey.value)
|
||||
codeStore.setCode(
|
||||
savedCode && storageKey.value.split("_").pop() === v
|
||||
? savedCode
|
||||
: problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<Form :storage-key="storageKey" @change-language="changeLanguage" />
|
||||
<CodeEditor
|
||||
v-model:value="codeStore.code.value"
|
||||
:language="codeStore.code.language"
|
||||
:height="editorHeight"
|
||||
@update:model-value="changeCode"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
161
apps/web/src/oj/problem/components/EditorForTest.vue
Normal file
161
apps/web/src/oj/problem/components/EditorForTest.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { SOURCES } from "utils/constants"
|
||||
import CodeEditor from "shared/components/CodeEditor.vue"
|
||||
import storage from "utils/storage"
|
||||
import { createTestSubmission } from "utils/judge"
|
||||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import type { DropdownOption } from "naive-ui"
|
||||
import { copyToClipboard } from "utils/functions"
|
||||
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const contestID = !!route.params.contestID ? route.params.contestID : null
|
||||
|
||||
const codeStore = useCodeStore()
|
||||
const problemStore = useProblemStore()
|
||||
const { input, output } = storeToRefs(codeStore)
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
|
||||
const storageKey = computed(
|
||||
() =>
|
||||
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (storage.get(storageKey.value)) {
|
||||
codeStore.setCode(storage.get(storageKey.value))
|
||||
} else {
|
||||
codeStore.setCode(
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function changeCode(v: string) {
|
||||
storage.set(storageKey.value, v)
|
||||
}
|
||||
|
||||
function changeLanguage(v: string) {
|
||||
if (
|
||||
storage.get(storageKey.value) &&
|
||||
storageKey.value.split("_").pop() === v
|
||||
) {
|
||||
codeStore.setCode(storage.get(storageKey.value))
|
||||
} else {
|
||||
codeStore.setCode(
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const copy = async () => {
|
||||
const success = await copyToClipboard(codeStore.code.value)
|
||||
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
codeStore.setCode(
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
)
|
||||
storage.remove(storageKey.value)
|
||||
message.success("代码重置成功")
|
||||
}
|
||||
|
||||
const runCode = async () => {
|
||||
const res = await createTestSubmission(codeStore.code, input.value)
|
||||
output.value = res.output
|
||||
}
|
||||
|
||||
const languageOptions: DropdownOption[] = problem.value!.languages.map(
|
||||
(it) => ({
|
||||
label: () => LANGUAGE_SHOW_VALUE[it],
|
||||
value: it,
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical style="height: calc(100vh - 92px)">
|
||||
<n-split direction="horizontal" :min="1 / 3" :max="4 / 5">
|
||||
<template #1>
|
||||
<n-flex vertical>
|
||||
<n-flex align="center">
|
||||
<n-select
|
||||
v-model:value="codeStore.code.language"
|
||||
style="width: 120px"
|
||||
:options="languageOptions"
|
||||
@update:value="changeLanguage"
|
||||
/>
|
||||
<n-button @click="copy">复制代码</n-button>
|
||||
<n-button @click="reset">重置代码</n-button>
|
||||
<n-button type="primary" secondary @click="runCode">
|
||||
运行代码
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<CodeEditor
|
||||
v-model:value="codeStore.code.value"
|
||||
@update:model-value="changeCode"
|
||||
:language="codeStore.code.language"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
<template #2>
|
||||
<n-split
|
||||
direction="vertical"
|
||||
:default-size="1 / 3"
|
||||
:min="1 / 5"
|
||||
:max="3 / 5"
|
||||
>
|
||||
<template #1>
|
||||
<div class="title">输入框</div>
|
||||
<n-input
|
||||
v-model:value="input"
|
||||
type="textarea"
|
||||
:bordered="false"
|
||||
:resizable="false"
|
||||
class="box"
|
||||
/>
|
||||
</template>
|
||||
<template #2>
|
||||
<div class="title">输出框</div>
|
||||
<n-input
|
||||
class="box output"
|
||||
v-model:value="output"
|
||||
placeholder=""
|
||||
type="textarea"
|
||||
:bordered="false"
|
||||
:resizable="false"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
</n-split>
|
||||
</template>
|
||||
</n-split>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.title {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
padding-left: 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.box {
|
||||
padding-left: 10px;
|
||||
box-sizing: border-box;
|
||||
height: calc(100% - 40px);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.output {
|
||||
font-family: "Monaco";
|
||||
}
|
||||
</style>
|
||||
291
apps/web/src/oj/problem/components/Form.vue
Normal file
291
apps/web/src/oj/problem/components/Form.vue
Normal file
@@ -0,0 +1,291 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from "pinia"
|
||||
import { copyToClipboard, utoa } from "utils/functions"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { injectSyncStatus } from "oj/composables/syncStatus"
|
||||
import { SYNC_MESSAGES } from "shared/composables/sync"
|
||||
import {
|
||||
ICON_SET,
|
||||
LANGUAGE_FORMAT_VALUE,
|
||||
LANGUAGE_SHOW_VALUE,
|
||||
SOURCES,
|
||||
STORAGE_KEY,
|
||||
} from "utils/constants"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import storage from "utils/storage"
|
||||
import type { LANGUAGE } from "utils/types"
|
||||
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { NFlex } from "naive-ui"
|
||||
import SubmitCode from "./SubmitCode.vue"
|
||||
|
||||
const SubmitFlowchart = defineAsyncComponent(
|
||||
() => import("./SubmitFlowchart.vue"),
|
||||
)
|
||||
|
||||
interface Props {
|
||||
storageKey: string
|
||||
isConnected?: boolean // WebSocket 实际的连接状态(已建立/未建立)
|
||||
}
|
||||
|
||||
const { storageKey, isConnected = false } = defineProps<Props>()
|
||||
|
||||
// 注入同步状态
|
||||
const syncStatus = injectSyncStatus()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changeLanguage: [v: LANGUAGE]
|
||||
toggleSync: [v: boolean]
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const codeStore = useCodeStore()
|
||||
const problemStore = useProblemStore()
|
||||
const { problem, languages } = storeToRefs(problemStore)
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const syncEnabled = ref(false) // 用户点击按钮后的意图状态(想要开启/关闭)
|
||||
const statisticPanel = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const isContestMode = computed(() => route.name === "contest problem")
|
||||
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
|
||||
const showSyncFeature = computed(
|
||||
() =>
|
||||
isDesktop.value &&
|
||||
userStore.isAuthed &&
|
||||
codeStore.code.language !== "Flowchart" &&
|
||||
!isContestMode.value,
|
||||
)
|
||||
|
||||
const showGoSubmissionButton = computed(() => {
|
||||
if (isContestMode.value) return true
|
||||
else if (userStore.isAdminRole) return true
|
||||
else if (userStore.showSubmissions) return true
|
||||
else return false
|
||||
})
|
||||
|
||||
const menuOptions = computed<DropdownOption[]>(() => {
|
||||
const options: DropdownOption[] = []
|
||||
// 移动端额外收纳桌面端常驻的两项
|
||||
if (!isDesktop.value) {
|
||||
if (showGoSubmissionButton.value) {
|
||||
options.push({
|
||||
label: "提交信息",
|
||||
key: "submissions",
|
||||
})
|
||||
}
|
||||
if (userStore.isTeacherOrAbove) {
|
||||
options.push({
|
||||
label: "课堂统计",
|
||||
key: "statistics",
|
||||
})
|
||||
}
|
||||
}
|
||||
if (codeStore.code.language !== "Flowchart") {
|
||||
if (codeStore.code.language !== "SQL") {
|
||||
options.push({
|
||||
label: "去自测猫",
|
||||
key: "testcat",
|
||||
})
|
||||
}
|
||||
options.push({
|
||||
label: "复制代码",
|
||||
key: "copy",
|
||||
})
|
||||
options.push({
|
||||
label: "重置代码",
|
||||
key: "reset",
|
||||
})
|
||||
}
|
||||
if (isDesktop.value && userStore.isSuperAdmin) {
|
||||
options.push({
|
||||
label: "编辑题目",
|
||||
key: "edit",
|
||||
})
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const handleMenuSelect = (key: string) => {
|
||||
switch (key) {
|
||||
case "submissions":
|
||||
goSubmissions()
|
||||
break
|
||||
case "statistics":
|
||||
statisticPanel.value = true
|
||||
break
|
||||
case "testcat":
|
||||
goTestCat()
|
||||
break
|
||||
case "copy":
|
||||
copy()
|
||||
break
|
||||
case "reset":
|
||||
reset()
|
||||
break
|
||||
case "edit":
|
||||
goEdit()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const languageOptions: DropdownOption[] = languages.value.map((it) => ({
|
||||
label: () =>
|
||||
h(NFlex, { align: "center" }, () => [
|
||||
h(Icon, {
|
||||
icon: ICON_SET[it],
|
||||
width: 16,
|
||||
}),
|
||||
LANGUAGE_SHOW_VALUE[it],
|
||||
]),
|
||||
value: it,
|
||||
}))
|
||||
|
||||
const copy = async () => {
|
||||
const success = await copyToClipboard(codeStore.code.value)
|
||||
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
codeStore.setCode(
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
)
|
||||
storage.remove(storageKey)
|
||||
message.success("代码重置成功")
|
||||
}
|
||||
|
||||
const changeLanguage = (v: LANGUAGE) => {
|
||||
storage.set(STORAGE_KEY.LANGUAGE, v)
|
||||
emit("changeLanguage", v)
|
||||
}
|
||||
|
||||
const goTestCat = () => {
|
||||
const lang = LANGUAGE_FORMAT_VALUE[codeStore.code.language]
|
||||
const data = {
|
||||
lang,
|
||||
code: codeStore.code.value,
|
||||
input: problemStore.problem?.samples[0].input,
|
||||
}
|
||||
const base64 = utoa(JSON.stringify(data))
|
||||
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
|
||||
window.open(url, "_blank")
|
||||
}
|
||||
|
||||
const goSubmissions = () => {
|
||||
const name = route.params.contestID ? "contest submissions" : "submissions"
|
||||
router.push({ name, query: { problem: problem.value!._id } })
|
||||
}
|
||||
|
||||
const goEdit = () => {
|
||||
const url = problem.value!.contest
|
||||
? `/admin/contest/${problem.value!.contest}/problem/edit/${problem.value!.id}`
|
||||
: `/admin/problem/edit/${problem.value!.id}`
|
||||
window.open(router.resolve(url).href, "_blank")
|
||||
}
|
||||
|
||||
const toggleSync = () => {
|
||||
syncEnabled.value = !syncEnabled.value
|
||||
emit("toggleSync", syncEnabled.value)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetSyncStatus: () => {
|
||||
syncEnabled.value = false
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!languages.value.includes(codeStore.code.language)) {
|
||||
// 回退到题目支持的第一种语言(如 SQL 题只有 "SQL",硬编码 Python3 会被后端拒绝)
|
||||
codeStore.code.language = languages.value[0] ?? "Python3"
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<n-select
|
||||
v-model:value="codeStore.code.language"
|
||||
style="width: 120px"
|
||||
:size="buttonSize"
|
||||
:options="languageOptions"
|
||||
@update:value="changeLanguage"
|
||||
/>
|
||||
|
||||
<SubmitFlowchart v-if="codeStore.code.language === 'Flowchart'" />
|
||||
|
||||
<SubmitCode v-else />
|
||||
|
||||
<n-button
|
||||
v-if="isDesktop && showGoSubmissionButton"
|
||||
:size="buttonSize"
|
||||
@click="goSubmissions"
|
||||
>
|
||||
提交信息
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
v-if="isDesktop && userStore.isTeacherOrAbove"
|
||||
:size="buttonSize"
|
||||
@click="statisticPanel = true"
|
||||
>
|
||||
课堂统计
|
||||
</n-button>
|
||||
|
||||
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;移动端再加上提交信息 / 课堂统计 -->
|
||||
<n-dropdown
|
||||
v-if="menuOptions.length"
|
||||
trigger="click"
|
||||
:options="menuOptions"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<n-button :size="buttonSize">更多操作</n-button>
|
||||
</n-dropdown>
|
||||
|
||||
<template v-if="showSyncFeature">
|
||||
<n-button
|
||||
:size="buttonSize"
|
||||
:type="syncEnabled ? 'warning' : 'default'"
|
||||
@click="toggleSync"
|
||||
>
|
||||
{{ syncEnabled ? SYNC_MESSAGES.SYNC_ON : SYNC_MESSAGES.SYNC_OFF }}
|
||||
</n-button>
|
||||
|
||||
<!-- 同步状态标签 -->
|
||||
<template v-if="isConnected">
|
||||
<n-tag v-if="syncStatus.otherUser.value" type="info">
|
||||
{{ SYNC_MESSAGES.SYNCING_WITH(syncStatus.otherUser.value.name) }}
|
||||
</n-tag>
|
||||
<n-tag
|
||||
v-if="
|
||||
userStore.isSuperAdmin &&
|
||||
!syncStatus.otherUser.value &&
|
||||
syncStatus.hadConnection.value
|
||||
"
|
||||
type="warning"
|
||||
>
|
||||
{{ SYNC_MESSAGES.STUDENT_LEFT(syncStatus.lastLeftUser.value?.name) }}
|
||||
</n-tag>
|
||||
</template>
|
||||
</template>
|
||||
</n-flex>
|
||||
|
||||
<n-modal
|
||||
v-if="userStore.isTeacherOrAbove"
|
||||
v-model:show="statisticPanel"
|
||||
preset="card"
|
||||
title="提交记录的统计"
|
||||
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
|
||||
:content-style="{ overflow: 'auto' }"
|
||||
>
|
||||
<StatisticsPanel :problem="problem!._id" username="" />
|
||||
</n-modal>
|
||||
</template>
|
||||
42
apps/web/src/oj/problem/components/MyFlowchartTab.vue
Normal file
42
apps/web/src/oj/problem/components/MyFlowchartTab.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||
import { useMermaid } from "shared/composables/useMermaid"
|
||||
|
||||
const store = useMyFlowchartStore()
|
||||
const { renderError, renderFlowchart } = useMermaid()
|
||||
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
||||
|
||||
watch(
|
||||
() => store.mermaidCode,
|
||||
async (code) => {
|
||||
if (!code) return
|
||||
await nextTick()
|
||||
await renderFlowchart(mermaidContainer.value, code)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="padding: 8px 0">
|
||||
<n-alert v-if="renderError" type="error" title="渲染失败" size="small">
|
||||
{{ renderError }}
|
||||
</n-alert>
|
||||
<div v-else ref="mermaidContainer" class="flowchart-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flowchart-container {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
:deep(.flowchart-container > svg) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
550
apps/web/src/oj/problem/components/ProblemContent.vue
Normal file
550
apps/web/src/oj/problem/components/ProblemContent.vue
Normal file
@@ -0,0 +1,550 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { createTestSubmission } from "utils/judge"
|
||||
import { DIFFICULTY } from "utils/constants"
|
||||
import type { Problem, ProblemStatus } from "utils/types"
|
||||
import Copy from "shared/components/Copy.vue"
|
||||
import { useDark } from "@vueuse/core"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import { getSimilarProblems } from "oj/api"
|
||||
import SQLDataTable from "./SQLDataTable.vue"
|
||||
|
||||
type Sample = Problem["samples"][number] & {
|
||||
id: number
|
||||
msg: string
|
||||
status: ProblemStatus
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const theme = useThemeVars()
|
||||
const style = computed(() => "color: " + theme.value.primaryColor)
|
||||
const isDark = useDark()
|
||||
const route = useRoute()
|
||||
const codeStore = useCodeStore()
|
||||
const problemStore = useProblemStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
|
||||
const problemSetId = computed(() => route.params.problemSetId)
|
||||
|
||||
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
|
||||
const isSQL = computed(() => !!problem.value?.sql_config)
|
||||
const sqlDisplay = computed(() => problem.value?.sql_display ?? null)
|
||||
const sqlExpectedQuery = computed(() => {
|
||||
const exp = sqlDisplay.value?.expected
|
||||
return exp && "columns" in exp ? exp : null
|
||||
})
|
||||
const sqlChangedTables = computed(() => {
|
||||
const exp = sqlDisplay.value?.expected
|
||||
return exp && "changed_tables" in exp ? exp.changed_tables : []
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 相似题目推荐
|
||||
const similarProblems = ref<any[]>([])
|
||||
const similarLoaded = ref(false)
|
||||
|
||||
async function loadSimilarProblems() {
|
||||
if (similarLoaded.value || !problem.value) return
|
||||
try {
|
||||
const res = await getSimilarProblems(problem.value._id)
|
||||
similarProblems.value = res.data || []
|
||||
} catch {
|
||||
similarProblems.value = []
|
||||
}
|
||||
similarLoaded.value = true
|
||||
}
|
||||
|
||||
// 切换题目时重置相似推荐状态
|
||||
watch(
|
||||
() => problem.value?._id,
|
||||
() => {
|
||||
similarProblems.value = []
|
||||
similarLoaded.value = false
|
||||
},
|
||||
)
|
||||
|
||||
// AC 或失败次数 >= 3 时加载推荐
|
||||
watch(
|
||||
() => [problem.value?._id, problem.value?.my_status, problemStore.failCount],
|
||||
([, status, failCount]) => {
|
||||
if (status === 0 || (failCount as number) >= 3) {
|
||||
loadSimilarProblems()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const hasTriedButNotPassed = computed(() => {
|
||||
return (
|
||||
problem.value?.my_status !== undefined &&
|
||||
problem.value?.my_status !== null &&
|
||||
problem.value?.my_status !== 0
|
||||
)
|
||||
})
|
||||
|
||||
const samples = ref<Sample[]>(
|
||||
problem.value!.samples.map((sample, index) => ({
|
||||
...sample,
|
||||
id: index,
|
||||
msg: "",
|
||||
status: "not_test",
|
||||
loading: false,
|
||||
})),
|
||||
)
|
||||
|
||||
const NODE_TARGET_LABELS: Record<string, string> = {
|
||||
for_loop: "for 循环",
|
||||
while_loop: "while 循环",
|
||||
if_statement: "if 条件",
|
||||
else_clause: "else 子句",
|
||||
function_definition: "函数定义",
|
||||
return: "return 语句",
|
||||
break: "break 语句",
|
||||
continue: "continue 语句",
|
||||
list_comprehension: "列表推导式",
|
||||
list_literal: "列表",
|
||||
dict_literal: "字典",
|
||||
set_literal: "集合",
|
||||
f_string: "f-string",
|
||||
try_except: "try-except",
|
||||
class_definition: "类定义",
|
||||
}
|
||||
|
||||
type AstRule = {
|
||||
engine: string
|
||||
target?: string
|
||||
label?: string
|
||||
exact?: number
|
||||
min?: number
|
||||
max?: number
|
||||
message: string
|
||||
}
|
||||
|
||||
function ruleDescription(rule: AstRule): string {
|
||||
if (rule.message) return rule.message
|
||||
const target = rule.target || ""
|
||||
const targetLabel = rule.label || NODE_TARGET_LABELS[target] || target
|
||||
const countDesc = () => {
|
||||
if (rule.exact !== undefined) return `出现 ${rule.exact} 次`
|
||||
if (rule.min !== undefined && rule.max !== undefined)
|
||||
return `出现 ${rule.min}~${rule.max} 次`
|
||||
if (rule.min !== undefined) return `至少出现 ${rule.min} 次`
|
||||
if (rule.max !== undefined) return `至多出现 ${rule.max} 次`
|
||||
return ""
|
||||
}
|
||||
const callDesc = () => {
|
||||
if (rule.exact !== undefined) return `调用 ${rule.exact} 次`
|
||||
if (rule.min !== undefined && rule.max !== undefined)
|
||||
return `调用 ${rule.min}~${rule.max} 次`
|
||||
if (rule.min !== undefined) return `至少调用 ${rule.min} 次`
|
||||
if (rule.max !== undefined) return `至多调用 ${rule.max} 次`
|
||||
return ""
|
||||
}
|
||||
switch (rule.engine) {
|
||||
case "must_exist_node":
|
||||
return `必须使用 ${targetLabel}`
|
||||
case "must_not_exist_node":
|
||||
return `不能使用 ${targetLabel}`
|
||||
case "count_node":
|
||||
return `${targetLabel} ${countDesc()}`
|
||||
case "must_call_function":
|
||||
return `必须调用 ${target}()`
|
||||
case "must_not_call_function":
|
||||
return `不能调用 ${target}()`
|
||||
case "count_function_call":
|
||||
return `${target}() ${callDesc()}`
|
||||
case "must_call_method":
|
||||
return `必须调用 .${target}()`
|
||||
case "must_not_call_method":
|
||||
return `不能调用 .${target}()`
|
||||
case "must_use_operator":
|
||||
return `必须使用 ${target} 运算符`
|
||||
default:
|
||||
return rule.engine
|
||||
}
|
||||
}
|
||||
|
||||
function ruleTagType(engine: string): "error" | "success" | "info" {
|
||||
if (engine.startsWith("must_not")) return "error"
|
||||
if (engine.startsWith("must")) return "success"
|
||||
return "info"
|
||||
}
|
||||
|
||||
const astRulesForDisplay = computed(() => {
|
||||
if (!problem.value?.ast_rules) return []
|
||||
return Object.entries(problem.value.ast_rules).filter(
|
||||
([, rules]) => rules.length > 0,
|
||||
)
|
||||
})
|
||||
|
||||
async function test(sample: Sample, index: number) {
|
||||
samples.value = samples.value.map((sample) => {
|
||||
if (sample.id === index) {
|
||||
sample.loading = true
|
||||
}
|
||||
return sample
|
||||
})
|
||||
const res = await createTestSubmission(codeStore.code, sample.input)
|
||||
samples.value = samples.value.map((sample) => {
|
||||
if (sample.id === index) {
|
||||
const status =
|
||||
res.status === 3 && res.output.trim() === sample.output
|
||||
? "passed"
|
||||
: "failed"
|
||||
return {
|
||||
...sample,
|
||||
msg: res.output,
|
||||
status: status,
|
||||
loading: false,
|
||||
}
|
||||
} else {
|
||||
return sample
|
||||
}
|
||||
})
|
||||
|
||||
const id = setTimeout(() => {
|
||||
clearTimeout(id)
|
||||
samples.value = samples.value.map((sample) => {
|
||||
if (sample.id === index) {
|
||||
return {
|
||||
...sample,
|
||||
msg: res.output,
|
||||
status: "not_test",
|
||||
loading: false,
|
||||
}
|
||||
} else {
|
||||
return sample
|
||||
}
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function label(status: ProblemStatus, loading: boolean) {
|
||||
if (loading) return "测试中"
|
||||
return {
|
||||
not_test: "测试",
|
||||
failed: "不通过",
|
||||
passed: "通过",
|
||||
}[status]
|
||||
}
|
||||
|
||||
function type(status: ProblemStatus) {
|
||||
return {
|
||||
not_test: "",
|
||||
failed: "error",
|
||||
passed: "success",
|
||||
}[status] as "warning" | "error" | "success"
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="problem">
|
||||
<template v-if="!problemSetId">
|
||||
<!-- 已通过 -->
|
||||
<n-alert
|
||||
class="status-alert"
|
||||
v-if="problem.my_status === 0"
|
||||
type="success"
|
||||
title="🎉 本 题 已 经 被 你 解 决 啦"
|
||||
>
|
||||
</n-alert>
|
||||
|
||||
<!-- 尝试过但未通过 -->
|
||||
<n-alert
|
||||
class="status-alert"
|
||||
v-else-if="hasTriedButNotPassed"
|
||||
type="warning"
|
||||
title="💪 你已经尝试过这道题,但还没有通过"
|
||||
>
|
||||
不要放弃!仔细检查代码逻辑,或者寻求 AI 的帮助获取灵感。
|
||||
</n-alert>
|
||||
</template>
|
||||
|
||||
<n-flex align="center">
|
||||
<n-tag>{{ problem._id }}</n-tag>
|
||||
<h2 class="problemTitle">{{ problem.title }}</h2>
|
||||
</n-flex>
|
||||
<p class="title" :style="style">
|
||||
<n-flex align="center">
|
||||
<Icon icon="streamline-ultimate-color:checklist"></Icon>
|
||||
描述
|
||||
</n-flex>
|
||||
</p>
|
||||
<MdPreview
|
||||
preview-theme="vuepress"
|
||||
:model-value="problem.description"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
|
||||
<template v-if="!isSQL">
|
||||
<p class="title" :style="style">
|
||||
<n-flex align="center">
|
||||
<Icon icon="streamline-ultimate-color:envelope-back-front"></Icon>
|
||||
输入
|
||||
</n-flex>
|
||||
</p>
|
||||
<MdPreview
|
||||
preview-theme="vuepress"
|
||||
:model-value="problem.input_description"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
|
||||
<p class="title" :style="style">
|
||||
<n-flex align="center">
|
||||
<Icon icon="streamline-ultimate-color:mailbox-post"></Icon>
|
||||
输出
|
||||
</n-flex>
|
||||
</p>
|
||||
<MdPreview
|
||||
preview-theme="vuepress"
|
||||
:model-value="problem.output_description"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="isSQL && sqlDisplay">
|
||||
<p class="title" :style="style">
|
||||
<n-flex align="center">
|
||||
<Icon icon="devicon:sqlite"></Icon>
|
||||
数据表
|
||||
</n-flex>
|
||||
</p>
|
||||
<div v-for="t in sqlDisplay.tables" :key="t.name">
|
||||
<p class="sqlTableName">{{ t.name }}</p>
|
||||
<SQLDataTable
|
||||
:columns="t.columns"
|
||||
:rows="t.rows"
|
||||
:total-rows="t.total_rows"
|
||||
:truncated="t.truncated"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="title" :style="style">
|
||||
<n-flex align="center">
|
||||
<Icon icon="streamline-ultimate-color:check-button"></Icon>
|
||||
期望结果
|
||||
</n-flex>
|
||||
</p>
|
||||
<template v-if="sqlExpectedQuery">
|
||||
<SQLDataTable
|
||||
:columns="sqlExpectedQuery.columns"
|
||||
:rows="sqlExpectedQuery.rows"
|
||||
:total-rows="sqlExpectedQuery.total_rows"
|
||||
:truncated="sqlExpectedQuery.truncated"
|
||||
/>
|
||||
<p v-if="!problem.sql_config?.order_sensitive" class="sqlNote">
|
||||
结果顺序不限
|
||||
</p>
|
||||
</template>
|
||||
<div v-for="t in sqlChangedTables" :key="t.name">
|
||||
<p class="sqlTableName">
|
||||
{{ t.dropped ? `${t.name} 表已被删除` : `执行后的 ${t.name} 表` }}
|
||||
</p>
|
||||
<SQLDataTable
|
||||
v-if="!t.dropped"
|
||||
:columns="t.columns"
|
||||
:rows="t.rows"
|
||||
:total-rows="t.total_rows"
|
||||
:truncated="t.truncated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="problem.hint">
|
||||
<p class="title" :style="style">
|
||||
<n-flex align="center">
|
||||
<Icon icon="streamline-emojis:man-tipping-hand-1"></Icon>
|
||||
提示
|
||||
</n-flex>
|
||||
</p>
|
||||
<MdPreview
|
||||
preview-theme="preview"
|
||||
:model-value="problem.hint"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 代码要求(AST 规则) -->
|
||||
<div v-if="astRulesForDisplay.length > 0">
|
||||
<p class="title" :style="style">
|
||||
<n-flex align="center">
|
||||
<Icon icon="streamline-ultimate-color:check-button"></Icon>
|
||||
要求
|
||||
</n-flex>
|
||||
</p>
|
||||
<div v-for="[lang, rules] in astRulesForDisplay" :key="lang">
|
||||
<p v-if="astRulesForDisplay.length > 1" class="lang-label">
|
||||
{{ lang }}
|
||||
</p>
|
||||
<n-list bordered style="margin-bottom: 8px">
|
||||
<n-list-item v-for="(rule, i) in rules" :key="i">
|
||||
<n-flex align="center">
|
||||
<n-tag :type="ruleTagType(rule.engine)">
|
||||
{{ ruleDescription(rule) }}
|
||||
</n-tag>
|
||||
<span v-if="rule.message" class="rule-message">{{
|
||||
rule.message
|
||||
}}</span>
|
||||
</n-flex>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!isSQL">
|
||||
<div v-for="(sample, index) of samples" :key="index">
|
||||
<n-flex align="center">
|
||||
<p class="title" :style="style">
|
||||
<n-flex align="center">
|
||||
<Icon icon="streamline-emojis:microscope"></Icon>
|
||||
例子 {{ index + 1 }}
|
||||
</n-flex>
|
||||
</p>
|
||||
<n-button
|
||||
size="small"
|
||||
:type="type(sample.status)"
|
||||
@click="test(sample, index)"
|
||||
>
|
||||
{{ label(sample.status, sample.loading) }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-descriptions
|
||||
bordered
|
||||
:column="2"
|
||||
label-style="width: 50%; min-width: 100px"
|
||||
>
|
||||
<n-descriptions-item>
|
||||
<template #label>
|
||||
<n-flex>
|
||||
<span>输入</span>
|
||||
<Copy :value="sample.input" />
|
||||
</n-flex>
|
||||
</template>
|
||||
<div class="testcase">{{ sample.input }}</div>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item>
|
||||
<template #label>
|
||||
<n-flex>
|
||||
<span>输出</span>
|
||||
<Copy :value="sample.output" />
|
||||
</n-flex>
|
||||
</template>
|
||||
<div class="testcase">{{ sample.output }}</div>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="运行结果" v-if="sample.msg">
|
||||
<div class="testcase">{{ sample.msg }}</div>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="problem.source">
|
||||
<p class="title" :style="style">
|
||||
<n-flex align="center">
|
||||
<Icon icon="streamline-ultimate-color:book-open-bookmark"></Icon>
|
||||
来源
|
||||
</n-flex>
|
||||
</p>
|
||||
<MdPreview
|
||||
preview-theme="vuepress"
|
||||
:model-value="problem.source"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 相似题目推荐 -->
|
||||
<div v-if="similarProblems.length > 0">
|
||||
<n-divider />
|
||||
<p class="title" :style="style">
|
||||
<n-flex align="center">
|
||||
<Icon icon="streamline-ultimate-color:like"></Icon>
|
||||
相似题目推荐
|
||||
</n-flex>
|
||||
</p>
|
||||
<n-list bordered>
|
||||
<n-list-item v-for="sp in similarProblems" :key="sp._id">
|
||||
<n-flex align="center" justify="space-between">
|
||||
<n-flex align="center">
|
||||
<n-tag size="small">{{ sp._id }}</n-tag>
|
||||
<n-button
|
||||
text
|
||||
type="info"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'problem',
|
||||
params: { problemID: sp._id },
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ sp.title }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-tag
|
||||
size="small"
|
||||
:type="
|
||||
sp.difficulty === 'Low'
|
||||
? 'success'
|
||||
: sp.difficulty === 'High'
|
||||
? 'error'
|
||||
: 'warning'
|
||||
"
|
||||
>
|
||||
{{
|
||||
DIFFICULTY[sp.difficulty as keyof typeof DIFFICULTY] || "中等"
|
||||
}}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.problemTitle {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.testcase {
|
||||
font-size: 14px;
|
||||
white-space: pre;
|
||||
font-family: "Monaco";
|
||||
}
|
||||
|
||||
.status-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.lang-label {
|
||||
font-weight: 600;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.rule-message {
|
||||
font-size: 13px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.sqlTableName {
|
||||
font-weight: 600;
|
||||
margin: 8px 0 4px;
|
||||
font-family: Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.sqlNote {
|
||||
font-size: 13px;
|
||||
opacity: 0.65;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
</style>
|
||||
123
apps/web/src/oj/problem/components/ProblemEditor.vue
Normal file
123
apps/web/src/oj/problem/components/ProblemEditor.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { provideSyncStatus } from "oj/composables/syncStatus"
|
||||
import { SOURCES } from "utils/constants"
|
||||
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import storage from "utils/storage"
|
||||
import type { LANGUAGE } from "utils/types"
|
||||
import Form from "./Form.vue"
|
||||
|
||||
const FlowchartEditor = defineAsyncComponent(
|
||||
() => import("shared/components/FlowchartEditor/index.vue"),
|
||||
)
|
||||
|
||||
const route = useRoute()
|
||||
const formRef = useTemplateRef<InstanceType<typeof Form>>("formRef")
|
||||
const flowchartEditorRef = useTemplateRef("flowchartEditorRef")
|
||||
|
||||
const codeStore = useCodeStore()
|
||||
const problemStore = useProblemStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const sync = ref(false)
|
||||
// 提供同步状态给子组件使用
|
||||
const syncStatus = provideSyncStatus()
|
||||
|
||||
const contestID = route.params.contestID || null
|
||||
const storageKey = computed(
|
||||
() =>
|
||||
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
)
|
||||
|
||||
const editorHeight = computed(() =>
|
||||
isDesktop.value ? "calc(100vh - 133px)" : "calc(100vh - 172px)",
|
||||
)
|
||||
|
||||
function loadCode() {
|
||||
const savedCode = storage.get(storageKey.value)
|
||||
codeStore.setCode(
|
||||
savedCode ||
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(loadCode)
|
||||
|
||||
watch(() => problem.value?._id, loadCode)
|
||||
|
||||
watch(
|
||||
() => codeStore.code.value,
|
||||
(v) => {
|
||||
storage.set(storageKey.value, v)
|
||||
},
|
||||
)
|
||||
|
||||
const changeCode = (v: string) => {
|
||||
storage.set(storageKey.value, v)
|
||||
}
|
||||
|
||||
const changeLanguage = (v: LANGUAGE) => {
|
||||
const savedCode = storage.get(storageKey.value)
|
||||
codeStore.setCode(
|
||||
savedCode && storageKey.value.split("_").pop() === v
|
||||
? savedCode
|
||||
: problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
)
|
||||
}
|
||||
|
||||
const toggleSync = (value: boolean) => {
|
||||
sync.value = value
|
||||
if (!value) {
|
||||
syncStatus.reset()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSyncClosed = () => {
|
||||
sync.value = false
|
||||
syncStatus.reset()
|
||||
formRef.value?.resetSyncStatus()
|
||||
}
|
||||
|
||||
const handleSyncStatusChange = (status: {
|
||||
otherUser?: { name: string; isSuperAdmin: boolean }
|
||||
}) => {
|
||||
syncStatus.setOtherUser(status.otherUser)
|
||||
}
|
||||
|
||||
// 提供FlowchartEditor的ref给子组件
|
||||
provide("flowchartEditorRef", flowchartEditorRef)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<Form
|
||||
ref="formRef"
|
||||
:storage-key="storageKey"
|
||||
:is-connected="sync"
|
||||
@change-language="changeLanguage"
|
||||
@toggle-sync="toggleSync"
|
||||
/>
|
||||
<FlowchartEditor
|
||||
v-if="codeStore.code.language === 'Flowchart'"
|
||||
ref="flowchartEditorRef"
|
||||
/>
|
||||
<SyncCodeEditor
|
||||
v-else
|
||||
v-model:value="codeStore.code.value"
|
||||
:sync="sync"
|
||||
:problem="problem!._id"
|
||||
:language="codeStore.code.language"
|
||||
:height="editorHeight"
|
||||
@update:model-value="changeCode"
|
||||
@sync-closed="handleSyncClosed"
|
||||
@sync-status-change="handleSyncStatusChange"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
43
apps/web/src/oj/problem/components/ProblemFlowchart.vue
Normal file
43
apps/web/src/oj/problem/components/ProblemFlowchart.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useMermaid } from "shared/composables/useMermaid"
|
||||
|
||||
const problemStore = useProblemStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
||||
|
||||
const { renderError, renderFlowchart } = useMermaid()
|
||||
|
||||
const renderProblemFlowchart = async () => {
|
||||
await renderFlowchart(
|
||||
mermaidContainer.value,
|
||||
problem.value?.mermaid_code ?? "",
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(renderProblemFlowchart)
|
||||
|
||||
watch(() => problem.value?.mermaid_code, renderProblemFlowchart)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
||||
<template #default>
|
||||
{{ renderError }}
|
||||
</template>
|
||||
</n-alert>
|
||||
<div v-else ref="mermaidContainer" class="container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
177
apps/web/src/oj/problem/components/ProblemInfo.vue
Normal file
177
apps/web/src/oj/problem/components/ProblemInfo.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
|
||||
import { getACRateNumber, getTagColor, parseTime } from "utils/functions"
|
||||
import { Pie } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
} from "chart.js"
|
||||
import { getProblemBeatRate } from "oj/api"
|
||||
import { getProblemYearlyAC, type YearlyACData } from "oj/api"
|
||||
import ProblemYearlyChart from "./ProblemYearlyChart.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
|
||||
// 仅注册饼图所需的 Chart.js 组件
|
||||
ChartJS.register(ArcElement, Title, Tooltip, Legend, Colors)
|
||||
|
||||
const problemStore = useProblemStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const beatRate = ref("0")
|
||||
const yearlyACData = ref<YearlyACData[]>([])
|
||||
|
||||
const data = computed(() => {
|
||||
const status = problem.value!.statistic_info
|
||||
const labels = []
|
||||
for (let i in status) {
|
||||
if (status[i] !== 0) {
|
||||
// @ts-ignore
|
||||
labels.push(JUDGE_STATUS[i]["name"])
|
||||
}
|
||||
}
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{ data: Object.values(status), hoverOffset: 5, borderRadius: 10 },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const numbers = computed(() => {
|
||||
return [
|
||||
{
|
||||
icon: "streamline-ultimate-color:checklist",
|
||||
title: problem.value?.submission_number ?? 0,
|
||||
content: "总提交",
|
||||
int: true,
|
||||
suffix: "",
|
||||
},
|
||||
{
|
||||
icon: "streamline-emojis:woman-raising-hand-2",
|
||||
title: problem.value?.accepted_number ?? 0,
|
||||
content: "通过数",
|
||||
int: true,
|
||||
suffix: "",
|
||||
},
|
||||
{
|
||||
icon: "fluent-emoji:chart-increasing",
|
||||
title: getACRateNumber(
|
||||
problem.value?.accepted_number ?? 0,
|
||||
problem.value?.submission_number ?? 0,
|
||||
),
|
||||
content: "通过率",
|
||||
int: false,
|
||||
suffix: "%",
|
||||
},
|
||||
{
|
||||
icon: "streamline-emojis:sparkles",
|
||||
title: parseFloat(beatRate.value),
|
||||
content: "击败用户",
|
||||
int: false,
|
||||
suffix: "%",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const options = {
|
||||
plugins: {
|
||||
title: { text: "提交结果的比例", display: true, font: { size: 20 } },
|
||||
},
|
||||
}
|
||||
|
||||
async function getBeatRate() {
|
||||
const res = await getProblemBeatRate(problem.value!.id)
|
||||
beatRate.value = res.data
|
||||
}
|
||||
|
||||
async function getYearlyAC() {
|
||||
const res = await getProblemYearlyAC(problem.value!._id)
|
||||
yearlyACData.value = res.data
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getBeatRate()
|
||||
getYearlyAC()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-descriptions
|
||||
bordered
|
||||
label-placement="left"
|
||||
:column="isDesktop ? 3 : 1"
|
||||
v-if="problem"
|
||||
>
|
||||
<n-descriptions-item label="编号">
|
||||
{{ problem._id }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="出题人">
|
||||
{{ problem.created_by.username }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">
|
||||
{{ parseTime(problem.create_time) }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="难度">
|
||||
<n-tag :type="getTagColor(problem.difficulty)">
|
||||
{{ DIFFICULTY[problem.difficulty] }}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item :span="2" label="标签">
|
||||
<n-flex>
|
||||
<n-tag type="info" v-for="tag in problem.tags" :key="tag">
|
||||
{{ tag }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-grid :cols="isDesktop ? 4 : 2" :x-gap="10" :y-gap="10" class="cards">
|
||||
<n-gi v-for="item in numbers" :key="item.content">
|
||||
<n-card hoverable>
|
||||
<n-flex vertical align="center">
|
||||
<Icon v-if="isDesktop" :icon="item.icon" width="40" />
|
||||
<n-h2 class="number">
|
||||
<n-number-animation
|
||||
:to="item.title"
|
||||
:precision="item.int ? 0 : 2"
|
||||
/>
|
||||
<span v-if="item.suffix">{{ item.suffix }}</span>
|
||||
</n-h2>
|
||||
<n-h4 class="number-label">{{ item.content }}</n-h4>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<div class="pie" v-if="problem && problem.submission_number > 0">
|
||||
<Pie :data="data" :options="options" />
|
||||
</div>
|
||||
<ProblemYearlyChart :data="yearlyACData" />
|
||||
</template>
|
||||
<style scoped>
|
||||
.cards {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.number {
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.number-label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pie {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
</style>
|
||||
28
apps/web/src/oj/problem/components/ProblemListTitle.vue
Normal file
28
apps/web/src/oj/problem/components/ProblemListTitle.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ProblemFiltered } from "utils/types"
|
||||
import { Icon } from "@iconify/vue"
|
||||
|
||||
defineProps<{
|
||||
problem: ProblemFiltered
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<span>{{ problem.title }}</span>
|
||||
<Icon
|
||||
v-if="problem.allow_flowchart"
|
||||
width="18"
|
||||
icon="vscode-icons:file-type-drawio"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="problem.show_flowchart"
|
||||
width="18"
|
||||
icon="vscode-icons:file-type-graphql"
|
||||
/>
|
||||
<Icon
|
||||
v-if="problem.has_ast_rules"
|
||||
width="18"
|
||||
icon="vscode-icons:file-type-light-todo"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
698
apps/web/src/oj/problem/components/ProblemReaction.vue
Normal file
698
apps/web/src/oj/problem/components/ProblemReaction.vue
Normal file
@@ -0,0 +1,698 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { storeToRefs } from "pinia"
|
||||
import type { CSSProperties } from "vue"
|
||||
import { getReaction, setReaction } from "oj/api"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { REACTIONS } from "utils/constants"
|
||||
import type { ReactionCounts, ReactionKey } from "utils/types"
|
||||
|
||||
const emit = defineEmits<{ submitted: [] }>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const problemStore = useProblemStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
const message = useMessage()
|
||||
const theme = useThemeVars()
|
||||
|
||||
const mine = ref<ReactionKey | null>(null)
|
||||
const counts = ref<ReactionCounts | null>(null)
|
||||
const loading = ref(false)
|
||||
// 正在提交的 key,用来锁住整组并只在当前选项显示进度。
|
||||
const submitting = ref<ReactionKey | null>(null)
|
||||
const activeIndex = ref<number | null>(null)
|
||||
const keyboardActive = ref(false)
|
||||
const wheelRef = ref<HTMLElement | null>(null)
|
||||
let loadSequence = 0
|
||||
|
||||
const wheelGeometry = {
|
||||
startAngle: -90,
|
||||
contentRadius: 35,
|
||||
pushRadius: 4,
|
||||
outerRadius: 50,
|
||||
arcPointCount: 9,
|
||||
hitInnerRadius: 0.18,
|
||||
hitOuterRadius: 0.49,
|
||||
} as const
|
||||
|
||||
const sliceAngle = 360 / REACTIONS.length
|
||||
|
||||
function pointOnCircle(angle: number, radius: number) {
|
||||
const radians = (angle * Math.PI) / 180
|
||||
return {
|
||||
x: 50 + Math.cos(radians) * radius,
|
||||
y: 50 + Math.sin(radians) * radius,
|
||||
}
|
||||
}
|
||||
|
||||
function getWheelItemStyle(index: number): CSSProperties {
|
||||
const centerAngle = wheelGeometry.startAngle + index * sliceAngle
|
||||
const startAngle = centerAngle - sliceAngle / 2
|
||||
const endAngle = centerAngle + sliceAngle / 2
|
||||
const dividerAngle = index * sliceAngle - sliceAngle / 2
|
||||
const position = pointOnCircle(centerAngle, wheelGeometry.contentRadius)
|
||||
const push = pointOnCircle(centerAngle, wheelGeometry.pushRadius)
|
||||
const arcPoints = Array.from(
|
||||
{ length: wheelGeometry.arcPointCount },
|
||||
(_, pointIndex) => {
|
||||
const progress = pointIndex / (wheelGeometry.arcPointCount - 1)
|
||||
const angle = startAngle + (endAngle - startAngle) * progress
|
||||
const point = pointOnCircle(angle, wheelGeometry.outerRadius)
|
||||
return `${point.x.toFixed(3)}% ${point.y.toFixed(3)}%`
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"--segment-path": `polygon(50% 50%, ${arcPoints.join(", ")})`,
|
||||
"--content-x": `${position.x}%`,
|
||||
"--content-y": `${position.y}%`,
|
||||
"--push-x": `${push.x - 50}px`,
|
||||
"--push-y": `${push.y - 50}px`,
|
||||
"--divider-angle": `${dividerAngle}deg`,
|
||||
}
|
||||
}
|
||||
|
||||
const wheelItems = REACTIONS.map((item, index) => ({
|
||||
...item,
|
||||
index,
|
||||
style: getWheelItemStyle(index),
|
||||
}))
|
||||
|
||||
const solved = computed(() => problem.value?.my_status === 0)
|
||||
const locked = computed(() => mine.value !== null)
|
||||
const canInteract = computed(
|
||||
() =>
|
||||
userStore.isAuthed &&
|
||||
!!problem.value &&
|
||||
solved.value &&
|
||||
!locked.value &&
|
||||
!loading.value &&
|
||||
!submitting.value,
|
||||
)
|
||||
|
||||
const wheelCenter = computed(() => {
|
||||
if (loading.value) {
|
||||
return {
|
||||
icon: "ph:spinner-gap-bold",
|
||||
eyebrow: "正在读取",
|
||||
label: "题目点评",
|
||||
spinning: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (submitting.value) {
|
||||
const item = REACTIONS.find((reaction) => reaction.key === submitting.value)
|
||||
return {
|
||||
icon: "svg-spinners:180-ring-with-bg",
|
||||
eyebrow: "正在记录",
|
||||
label: item?.label ?? "提交点评",
|
||||
spinning: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (mine.value) {
|
||||
const item = REACTIONS.find((reaction) => reaction.key === mine.value)
|
||||
return {
|
||||
icon: "ph:check-bold",
|
||||
eyebrow: "你的选择",
|
||||
label: item?.label ?? "已提交",
|
||||
spinning: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (!userStore.isAuthed) {
|
||||
return {
|
||||
icon: "ph:user-circle-dashed",
|
||||
eyebrow: "登录后开放",
|
||||
label: "登录后点评",
|
||||
spinning: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (activeIndex.value !== null) {
|
||||
const item = wheelItems[activeIndex.value]
|
||||
const count = counts.value?.[item.key]
|
||||
return {
|
||||
icon: item.icon,
|
||||
eyebrow: count === undefined ? "选择这项" : `${count} 人选择`,
|
||||
label: item.label,
|
||||
spinning: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (!solved.value) {
|
||||
return {
|
||||
icon: "ph:lock-simple-bold",
|
||||
eyebrow: "完成后开放",
|
||||
label: "通关后点评",
|
||||
spinning: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: "ph:cursor-click-bold",
|
||||
eyebrow: "移动到扇区",
|
||||
label: "选择点评",
|
||||
spinning: false,
|
||||
}
|
||||
})
|
||||
|
||||
const reactionStyle = computed(() => ({
|
||||
"--reaction-accent": theme.value.primaryColor,
|
||||
"--reaction-card": theme.value.cardColor,
|
||||
"--reaction-border": theme.value.borderColor,
|
||||
"--reaction-text": theme.value.textColor1,
|
||||
"--reaction-text-faint": theme.value.textColor3,
|
||||
}))
|
||||
|
||||
function optionAriaLabel(key: ReactionKey, label: string) {
|
||||
const count = counts.value?.[key]
|
||||
const countText = count === undefined ? "" : `,${count} 人选择`
|
||||
const selectedText = mine.value === key ? ",你的选择" : ""
|
||||
return `${label}${countText}${selectedText}`
|
||||
}
|
||||
|
||||
function getPointerIndex(event: PointerEvent | MouseEvent) {
|
||||
const wheel = wheelRef.value
|
||||
if (!wheel) return null
|
||||
|
||||
const bounds = wheel.getBoundingClientRect()
|
||||
const x = event.clientX - (bounds.left + bounds.width / 2)
|
||||
const y = event.clientY - (bounds.top + bounds.height / 2)
|
||||
const distance = Math.hypot(x, y)
|
||||
|
||||
if (
|
||||
distance < bounds.width * wheelGeometry.hitInnerRadius ||
|
||||
distance > bounds.width * wheelGeometry.hitOuterRadius
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const angle = (Math.atan2(y, x) * 180) / Math.PI
|
||||
const rawIndex = Math.round((angle - wheelGeometry.startAngle) / sliceAngle)
|
||||
return (
|
||||
((rawIndex % wheelItems.length) + wheelItems.length) % wheelItems.length
|
||||
)
|
||||
}
|
||||
|
||||
function preview(index: number, fromKeyboard = false) {
|
||||
if (!canInteract.value) return
|
||||
keyboardActive.value = fromKeyboard
|
||||
activeIndex.value = index
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
if (locked.value) return
|
||||
activeIndex.value = null
|
||||
keyboardActive.value = false
|
||||
}
|
||||
|
||||
function onWheelPointerMove(event: PointerEvent) {
|
||||
if (!canInteract.value) return
|
||||
keyboardActive.value = false
|
||||
activeIndex.value = getPointerIndex(event)
|
||||
}
|
||||
|
||||
function onWheelClick(event: MouseEvent) {
|
||||
if (!canInteract.value) return
|
||||
const target = event.target
|
||||
if (target instanceof Element && target.closest(".reaction-option")) return
|
||||
|
||||
const index = getPointerIndex(event)
|
||||
if (index !== null) pick(wheelItems[index].key)
|
||||
}
|
||||
|
||||
async function pick(key: ReactionKey) {
|
||||
if (
|
||||
!problem.value ||
|
||||
!solved.value ||
|
||||
locked.value ||
|
||||
loading.value ||
|
||||
submitting.value
|
||||
)
|
||||
return
|
||||
|
||||
activeIndex.value = null
|
||||
keyboardActive.value = false
|
||||
submitting.value = key
|
||||
try {
|
||||
const res = await setReaction(problem.value.id, key)
|
||||
mine.value = res.data.mine
|
||||
counts.value = res.data.counts
|
||||
emit("submitted")
|
||||
} catch {
|
||||
message.error("提交失败,请重试")
|
||||
} finally {
|
||||
submitting.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function load(problemId: number) {
|
||||
const sequence = ++loadSequence
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getReaction(problemId)
|
||||
if (sequence !== loadSequence) return
|
||||
mine.value = res.data.mine
|
||||
counts.value = res.data.counts
|
||||
} catch {
|
||||
if (sequence === loadSequence) message.error("暂时无法读取题目点评")
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => userStore.isAuthed, () => problem.value?.id],
|
||||
([isAuthed, problemId]) => {
|
||||
mine.value = null
|
||||
counts.value = null
|
||||
submitting.value = null
|
||||
activeIndex.value = null
|
||||
keyboardActive.value = false
|
||||
|
||||
if (!isAuthed || problemId === undefined) {
|
||||
loadSequence += 1
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
load(problemId)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="reaction-panel" :style="reactionStyle" aria-label="题目点评">
|
||||
<div class="wheel-stage">
|
||||
<div
|
||||
ref="wheelRef"
|
||||
class="reaction-wheel"
|
||||
:class="{
|
||||
'has-selection': locked,
|
||||
'is-disabled': !canInteract,
|
||||
'is-keyboard-active': keyboardActive,
|
||||
}"
|
||||
role="group"
|
||||
aria-label="选择一项题目点评,点击后立即提交"
|
||||
:aria-busy="loading || !!submitting"
|
||||
@pointermove="onWheelPointerMove"
|
||||
@pointerleave="clearPreview"
|
||||
@click="onWheelClick"
|
||||
>
|
||||
<span
|
||||
v-for="item in wheelItems"
|
||||
:key="`${item.key}-face`"
|
||||
class="segment-face"
|
||||
:class="{
|
||||
'is-active': activeIndex === item.index,
|
||||
'is-selected': mine === item.key,
|
||||
'is-submitting': submitting === item.key,
|
||||
}"
|
||||
:style="item.style"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-for="item in wheelItems"
|
||||
:key="`${item.key}-divider`"
|
||||
class="segment-divider"
|
||||
:style="item.style"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-for="item in wheelItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="reaction-option"
|
||||
:class="{
|
||||
'is-active': activeIndex === item.index,
|
||||
'is-selected': mine === item.key,
|
||||
'is-submitting': submitting === item.key,
|
||||
'is-muted': locked && mine !== item.key,
|
||||
'is-unavailable': !userStore.isAuthed || !solved || loading,
|
||||
}"
|
||||
:style="item.style"
|
||||
:disabled="!canInteract"
|
||||
:aria-pressed="mine === item.key"
|
||||
:aria-label="optionAriaLabel(item.key, item.label)"
|
||||
@focus="preview(item.index, true)"
|
||||
@blur="clearPreview"
|
||||
@click.stop="pick(item.key)"
|
||||
>
|
||||
<span class="option-content">
|
||||
<span class="option-icon" aria-hidden="true">
|
||||
<Icon
|
||||
:icon="
|
||||
submitting === item.key
|
||||
? 'svg-spinners:180-ring-with-bg'
|
||||
: item.icon
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
<span class="option-label">{{ item.label }}</span>
|
||||
<span v-if="counts" class="option-count">
|
||||
{{ counts[item.key] }} 人
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="wheel-core" aria-hidden="true">
|
||||
<div class="core-content">
|
||||
<Icon
|
||||
class="core-icon"
|
||||
:class="{ 'is-spinning': wheelCenter.spinning }"
|
||||
:icon="wheelCenter.icon"
|
||||
/>
|
||||
<span class="core-eyebrow">{{ wheelCenter.eyebrow }}</span>
|
||||
<strong class="core-label">{{ wheelCenter.label }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reaction-panel {
|
||||
width: min(100%, 720px);
|
||||
box-sizing: border-box;
|
||||
container-type: inline-size;
|
||||
margin: 0 auto;
|
||||
padding: clamp(12px, 3vw, 24px);
|
||||
color: var(--reaction-text);
|
||||
}
|
||||
|
||||
.wheel-stage {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.reaction-wheel {
|
||||
position: relative;
|
||||
width: min(100%, 400px);
|
||||
box-sizing: border-box;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--reaction-border);
|
||||
border-radius: 50%;
|
||||
background: var(--reaction-card);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.segment-face {
|
||||
position: absolute;
|
||||
inset: 1px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--reaction-text) 4%, var(--reaction-card));
|
||||
clip-path: var(--segment-path);
|
||||
transform: translate(0, 0) scale(1);
|
||||
transform-origin: center;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
transform 140ms cubic-bezier(0, 0, 0.2, 1),
|
||||
background-color 140ms ease-out;
|
||||
}
|
||||
|
||||
.segment-face:is(.is-active, .is-submitting) {
|
||||
z-index: 2;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--reaction-accent) 10%,
|
||||
var(--reaction-card)
|
||||
);
|
||||
transform: translate(calc(var(--push-x) * 0.7), calc(var(--push-y) * 0.7))
|
||||
scale(1.045);
|
||||
}
|
||||
|
||||
.segment-face.is-selected {
|
||||
z-index: 3;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--reaction-accent) 18%,
|
||||
var(--reaction-card)
|
||||
);
|
||||
transform: translate(var(--push-x), var(--push-y)) scale(1.075);
|
||||
}
|
||||
|
||||
.segment-divider {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
top: 1px;
|
||||
left: calc(50% - 1px);
|
||||
width: 2px;
|
||||
height: calc(50% - 1px);
|
||||
background: var(--reaction-border);
|
||||
transform: rotate(var(--divider-angle));
|
||||
transform-origin: 50% 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.reaction-option {
|
||||
position: absolute;
|
||||
z-index: 9;
|
||||
top: var(--content-y);
|
||||
left: var(--content-x);
|
||||
display: flex;
|
||||
width: clamp(66px, 20%, 80px);
|
||||
min-height: clamp(52px, 16%, 64px);
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--reaction-text);
|
||||
font: inherit;
|
||||
transform: translate(-50%, -50%);
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reaction-option:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.reaction-option.is-unavailable {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.reaction-option.is-muted {
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.reaction-option:is(.is-selected, .is-submitting) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.reaction-option:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--reaction-accent) 55%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.option-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
border-radius: 6px;
|
||||
transform: translate(0, 0) scale(1);
|
||||
transition: transform 140ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.reaction-option:active:not(:disabled) .option-content {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.reaction-option:is(.is-active, .is-submitting) .option-content {
|
||||
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8))
|
||||
scale(1.18);
|
||||
}
|
||||
|
||||
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled)
|
||||
.option-content {
|
||||
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8))
|
||||
scale(1.1);
|
||||
}
|
||||
|
||||
.reaction-option.is-selected .option-content {
|
||||
transform: translate(var(--push-x), var(--push-y)) scale(1.24);
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.option-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.option-count {
|
||||
color: var(--reaction-text-faint);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.reaction-option.is-selected .option-count {
|
||||
color: var(--reaction-text);
|
||||
}
|
||||
|
||||
.wheel-core {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: grid;
|
||||
width: 31%;
|
||||
aspect-ratio: 1;
|
||||
place-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--reaction-border);
|
||||
border-radius: 50%;
|
||||
background: var(--reaction-card);
|
||||
color: var(--reaction-text);
|
||||
text-align: center;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.core-content {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.core-icon {
|
||||
width: clamp(20px, 5cqi, 26px);
|
||||
height: clamp(20px, 5cqi, 26px);
|
||||
font-size: clamp(20px, 5cqi, 26px);
|
||||
color: var(--reaction-text-faint);
|
||||
}
|
||||
|
||||
.core-eyebrow {
|
||||
color: var(--reaction-text-faint);
|
||||
font-size: clamp(9px, 2.3cqi, 11px);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.core-label {
|
||||
font-size: clamp(12px, 3cqi, 15px);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.reaction-wheel.is-keyboard-active :is(.segment-face, .option-content) {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
|
||||
.is-spinning {
|
||||
animation: reaction-spin 850ms linear infinite;
|
||||
}
|
||||
|
||||
@container (max-width: 440px) {
|
||||
.wheel-stage {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.reaction-option {
|
||||
width: clamp(58px, 20%, 70px);
|
||||
min-height: clamp(48px, 16%, 56px);
|
||||
}
|
||||
|
||||
.segment-face:is(.is-active, .is-submitting) {
|
||||
transform: translate(calc(var(--push-x) * 0.5), calc(var(--push-y) * 0.5))
|
||||
scale(1.03);
|
||||
}
|
||||
|
||||
.segment-face.is-selected {
|
||||
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75))
|
||||
scale(1.05);
|
||||
}
|
||||
|
||||
.option-content {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.reaction-option:is(.is-active, .is-submitting) .option-content {
|
||||
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55))
|
||||
scale(1.12);
|
||||
}
|
||||
|
||||
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled)
|
||||
.option-content {
|
||||
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55))
|
||||
scale(1.06);
|
||||
}
|
||||
|
||||
.reaction-option.is-selected .option-content {
|
||||
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75))
|
||||
scale(1.17);
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.option-icon svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.option-count {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wheel-core {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.segment-face,
|
||||
.option-content,
|
||||
.option-icon,
|
||||
.option-label,
|
||||
.option-count {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
|
||||
.is-spinning {
|
||||
animation-duration: 1.8s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reaction-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
24
apps/web/src/oj/problem/components/ProblemStatus.vue
Normal file
24
apps/web/src/oj/problem/components/ProblemStatus.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
|
||||
const theme = useThemeVars()
|
||||
const props = defineProps<{
|
||||
status: "not_test" | "passed" | "failed"
|
||||
}>()
|
||||
|
||||
const showIcon = computed(() => props.status !== "not_test")
|
||||
const color = computed(() => {
|
||||
if (props.status === "passed") return theme.value.successColor
|
||||
if (props.status === "failed") return theme.value.errorColor
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-icon v-if="showIcon" :color="color">
|
||||
<Icon icon="ph:check-bold" v-if="status === 'passed'"></Icon>
|
||||
<Icon icon="ph:minus-bold" v-if="status === 'failed'"></Icon>
|
||||
</n-icon>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
343
apps/web/src/oj/problem/components/ProblemSubmission.vue
Normal file
343
apps/web/src/oj/problem/components/ProblemSubmission.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton, NFlex, NTooltip } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { getSubmissions, getRankOfProblem } from "oj/api"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
import type { Submission } from "utils/types"
|
||||
import SubmissionDetail from "oj/submission/detail.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
// 弹框状态管理
|
||||
const [codePanelVisible, toggleCodePanel] = useToggle(false)
|
||||
const submissionID = ref("")
|
||||
const problemID = ref("")
|
||||
|
||||
// 显示代码弹框
|
||||
function showCodePanel(id: string, problem: string) {
|
||||
submissionID.value = id
|
||||
problemID.value = problem
|
||||
toggleCodePanel(true)
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<Submission>[] = [
|
||||
{
|
||||
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
|
||||
key: "create_time",
|
||||
width: 200,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("编号", "fluent-emoji-flat:input-numbers"),
|
||||
key: "id",
|
||||
minWidth: 160,
|
||||
render: (row) => {
|
||||
if (!row.show_link)
|
||||
return h(NFlex, { align: "center" }, () => [
|
||||
h("span", row.id.slice(0, 12)),
|
||||
h(
|
||||
NTooltip,
|
||||
{},
|
||||
{
|
||||
trigger: () =>
|
||||
h(NButton, { text: true }, () =>
|
||||
h(Icon, { icon: "catppuccin:lock" }),
|
||||
),
|
||||
default: () =>
|
||||
"这道题在你已经加入的题单中,只有在题单中完成此题,代码才可见。",
|
||||
},
|
||||
),
|
||||
])
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
type: "info",
|
||||
onClick: () => {
|
||||
showCodePanel(row.id, (route.params.problemID as string) ?? "")
|
||||
},
|
||||
},
|
||||
() => row.id.slice(0, 12),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("状态", "streamline-emojis:panda-face"),
|
||||
key: "status",
|
||||
width: 140,
|
||||
render: (row) => h(SubmissionResultTag, { result: row.result }),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("语言", "streamline-ultimate-color:earth-pin-2"),
|
||||
key: "language",
|
||||
width: 100,
|
||||
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
|
||||
},
|
||||
]
|
||||
|
||||
const class_name = ref("")
|
||||
const rank = ref(-1)
|
||||
const class_ac_count = ref(0)
|
||||
const all_ac_count = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const submissions = ref<Submission[]>([])
|
||||
const total = ref(0)
|
||||
const query = reactive({
|
||||
limit: 10,
|
||||
page: 1,
|
||||
})
|
||||
|
||||
// 错误分布统计
|
||||
const statusDistribution = computed(() => {
|
||||
if (!submissions.value.length) return []
|
||||
const counts = new Map<number, number>()
|
||||
for (const s of submissions.value) {
|
||||
counts.set(s.result, (counts.get(s.result) || 0) + 1)
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([result, count]) => ({
|
||||
result,
|
||||
name: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name || "未知",
|
||||
type: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.type || "info",
|
||||
count,
|
||||
}))
|
||||
})
|
||||
|
||||
const errorMsg = computed(() => {
|
||||
if (!userStore.isAuthed) return "请先登录"
|
||||
else if (!userStore.showSubmissions) return "提交列表已被管理员关闭"
|
||||
else return ""
|
||||
})
|
||||
|
||||
async function listSubmissions() {
|
||||
const offset = query.limit * (query.page - 1)
|
||||
const res = await getSubmissions({
|
||||
...query,
|
||||
myself: "1",
|
||||
offset,
|
||||
problem_id: (route.params.problemID as string) ?? "",
|
||||
contest_id: (route.params.contestID as string) ?? "",
|
||||
})
|
||||
submissions.value = res.data.results
|
||||
total.value = res.data.total
|
||||
}
|
||||
|
||||
async function getRankOfThisProblem() {
|
||||
loading.value = true
|
||||
const res = await getRankOfProblem((route.params.problemID as string) ?? "")
|
||||
loading.value = false
|
||||
|
||||
class_name.value = res.data.class_name
|
||||
rank.value = res.data.rank
|
||||
class_ac_count.value = res.data.class_ac_count
|
||||
all_ac_count.value = res.data.all_ac_count
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
listSubmissions()
|
||||
if (route.name === "problem") {
|
||||
getRankOfThisProblem()
|
||||
}
|
||||
})
|
||||
watch(query, listSubmissions)
|
||||
</script>
|
||||
<template>
|
||||
<n-alert
|
||||
class="tip"
|
||||
type="error"
|
||||
v-if="!userStore.showSubmissions || !userStore.isAuthed"
|
||||
:title="errorMsg"
|
||||
/>
|
||||
|
||||
<template v-if="!loading && route.name === 'problem' && userStore.isAuthed">
|
||||
<template v-if="class_name">
|
||||
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<span>
|
||||
本道题你在班上排名第 <b>{{ rank }}</b
|
||||
>,你们班共有 <b>{{ class_ac_count }}</b> 人答案正确
|
||||
</span>
|
||||
<n-button
|
||||
secondary
|
||||
v-if="userStore.showSubmissions"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'submissions',
|
||||
query: {
|
||||
problem: route.params.problemID,
|
||||
result: '0',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
username: 'ks' + class_name,
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
查看
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
<n-alert
|
||||
class="tip"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
v-if="rank === -1 && class_ac_count > 0"
|
||||
>
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<span>
|
||||
本道题你还没有解决,你们班共有
|
||||
<b>{{ class_ac_count }}</b> 人答案正确
|
||||
</span>
|
||||
<n-button
|
||||
v-if="userStore.showSubmissions"
|
||||
secondary
|
||||
@click="
|
||||
router.push({
|
||||
name: 'submissions',
|
||||
query: {
|
||||
problem: route.params.problemID,
|
||||
result: '0',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
username: 'ks' + class_name,
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
查看
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<span>
|
||||
本道题你在全服排名第 <b>{{ rank }}</b
|
||||
>,全服共有 <b>{{ all_ac_count }}</b> 人答案正确
|
||||
</span>
|
||||
<n-button
|
||||
secondary
|
||||
v-if="userStore.showSubmissions"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'submissions',
|
||||
query: {
|
||||
problem: route.params.problemID,
|
||||
result: '0',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
查看
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
<n-alert
|
||||
class="tip"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
v-if="rank === -1 && all_ac_count > 0"
|
||||
>
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<span>
|
||||
本道题你还没有解决,全服共有 <b>{{ all_ac_count }}</b> 人答案正确
|
||||
</span>
|
||||
<n-button
|
||||
v-if="userStore.showSubmissions"
|
||||
secondary
|
||||
@click="
|
||||
router.push({
|
||||
name: 'submissions',
|
||||
query: {
|
||||
problem: route.params.problemID,
|
||||
result: '0',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
查看
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="userStore.showSubmissions && userStore.isAuthed">
|
||||
<!-- 错误分布统计 -->
|
||||
<n-flex
|
||||
v-if="statusDistribution.length"
|
||||
class="tip"
|
||||
align="center"
|
||||
:wrap="true"
|
||||
>
|
||||
<span style="font-weight: bold; font-size: 13px">我的提交统计:</span>
|
||||
<n-tag
|
||||
v-for="item in statusDistribution"
|
||||
:key="item.result"
|
||||
:type="item.type as any"
|
||||
size="small"
|
||||
round
|
||||
>
|
||||
{{ item.name }} × {{ item.count }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
|
||||
<n-data-table
|
||||
v-if="submissions.length > 0"
|
||||
striped
|
||||
:columns="columns"
|
||||
:data="submissions"
|
||||
/>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 代码详情弹框 -->
|
||||
<n-modal
|
||||
v-model:show="codePanelVisible"
|
||||
preset="card"
|
||||
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
|
||||
:content-style="{ overflow: 'auto' }"
|
||||
title="代码详情"
|
||||
>
|
||||
<SubmissionDetail
|
||||
:problemID="problemID"
|
||||
:submissionID="submissionID"
|
||||
hideList
|
||||
@copied="toggleCodePanel(false)"
|
||||
/>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tip {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
85
apps/web/src/oj/problem/components/ProblemYearlyChart.vue
Normal file
85
apps/web/src/oj/problem/components/ProblemYearlyChart.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="yearly-chart" v-if="props.data.length > 1">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Line } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "chart.js"
|
||||
import type { YearlyACData } from "oj/api"
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
Filler,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
)
|
||||
|
||||
const props = defineProps<{ data: YearlyACData[] }>()
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.data.map((d) => String(d.year)),
|
||||
datasets: [
|
||||
{
|
||||
label: "AC 率",
|
||||
data: props.data.map((d) => d.ac_rate),
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
backgroundColor: "rgba(99, 179, 237, 0.2)",
|
||||
borderColor: "rgba(99, 179, 237, 1)",
|
||||
pointBackgroundColor: "rgba(99, 179, 237, 1)",
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: "历年 AC 率",
|
||||
font: { size: 20 },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const d = props.data[context.dataIndex]
|
||||
return [`AC 率: ${d.ac_rate}%`, `通过: ${d.accepted} / ${d.total}`]
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: {
|
||||
callback: (value: any) => `${value}%`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.yearly-chart {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
height: 250px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
</style>
|
||||
60
apps/web/src/oj/problem/components/SQLDataTable.vue
Normal file
60
apps/web/src/oj/problem/components/SQLDataTable.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { SQLDisplayColumn } from "utils/types"
|
||||
|
||||
defineProps<{
|
||||
columns: SQLDisplayColumn[]
|
||||
rows: (string | number | null)[][]
|
||||
totalRows?: number
|
||||
truncated?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-table class="sqlTable" size="small" :single-line="false">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="(col, i) in columns" :key="i">
|
||||
{{ col.name }}
|
||||
<span v-if="col.type" class="colType">{{ col.type }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="rows.length === 0">
|
||||
<td :colspan="columns.length" class="nullCell">(空表)</td>
|
||||
</tr>
|
||||
<tr v-for="(row, i) in rows" :key="i">
|
||||
<td v-for="(v, j) in row" :key="j" :class="{ nullCell: v === null }">
|
||||
{{ v === null ? "NULL" : v }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
<p v-if="truncated" class="truncNote">
|
||||
共 {{ totalRows }} 行,仅展示前 {{ rows.length }} 行
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sqlTable {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.colType {
|
||||
font-size: 12px;
|
||||
opacity: 0.55;
|
||||
margin-left: 4px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.nullCell {
|
||||
opacity: 0.45;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.truncNote {
|
||||
font-size: 13px;
|
||||
opacity: 0.65;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
</style>
|
||||
237
apps/web/src/oj/problem/components/SubmissionResult.vue
Normal file
237
apps/web/src/oj/problem/components/SubmissionResult.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||
import {
|
||||
getCSRFToken,
|
||||
submissionMemoryFormat,
|
||||
submissionTimeFormat,
|
||||
} from "utils/functions"
|
||||
import type { Submission } from "utils/types"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { consumeJSONEventStream } from "utils/stream"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import { useDark } from "@vueuse/core"
|
||||
|
||||
const props = defineProps<{
|
||||
submission?: Submission
|
||||
}>()
|
||||
|
||||
const isDark = useDark()
|
||||
const problemStore = useProblemStore()
|
||||
const theme = useThemeVars()
|
||||
|
||||
// AI 提示状态
|
||||
const hintContent = ref("")
|
||||
const hintLoading = ref(false)
|
||||
const hintError = ref("")
|
||||
|
||||
// 错误信息格式化
|
||||
const msg = computed(() => {
|
||||
if (!props.submission) return ""
|
||||
|
||||
let msg = ""
|
||||
const result = props.submission.result
|
||||
|
||||
// 编译错误或运行时错误时给出提示;
|
||||
// SQL 题的运行错误多半是"查询题里写了增删改"这类被判题拒绝的语句,err_info 已说明原因,不套这句
|
||||
if (
|
||||
(result === SubmissionStatus.compile_error ||
|
||||
result === SubmissionStatus.runtime_error) &&
|
||||
props.submission.language !== "SQL"
|
||||
) {
|
||||
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
|
||||
}
|
||||
|
||||
if (
|
||||
result !== SubmissionStatus.ast_check_failed &&
|
||||
props.submission.statistic_info?.err_info
|
||||
) {
|
||||
msg += props.submission.statistic_info.err_info
|
||||
}
|
||||
|
||||
return msg
|
||||
})
|
||||
|
||||
// 是否显示AI提示区域
|
||||
const showAIHint = computed(() => {
|
||||
if (!props.submission) return false
|
||||
return (
|
||||
problemStore.failCount >= 3 &&
|
||||
props.submission.result !== SubmissionStatus.accepted &&
|
||||
props.submission.result !== SubmissionStatus.ast_check_failed &&
|
||||
props.submission.result !== SubmissionStatus.pending &&
|
||||
props.submission.result !== SubmissionStatus.judging &&
|
||||
props.submission.result !== SubmissionStatus.submitting
|
||||
)
|
||||
})
|
||||
|
||||
async function fetchHint(submissionId: string) {
|
||||
hintLoading.value = true
|
||||
hintContent.value = ""
|
||||
hintError.value = ""
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
const csrfToken = getCSRFToken()
|
||||
if (csrfToken) {
|
||||
headers["X-CSRFToken"] = csrfToken
|
||||
}
|
||||
|
||||
const response = await fetch("/api/ai/hint", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ submission_id: submissionId }),
|
||||
})
|
||||
|
||||
await consumeJSONEventStream(response, {
|
||||
onMessage: (data: {
|
||||
type: string
|
||||
content?: string
|
||||
message?: string
|
||||
}) => {
|
||||
if (data.type === "delta" && data.content) {
|
||||
hintContent.value += data.content
|
||||
} else if (data.type === "error") {
|
||||
hintError.value = data.message || "AI 提示生成失败"
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (e: any) {
|
||||
hintError.value = e.message || "请求失败"
|
||||
} finally {
|
||||
hintLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试用例表格数据(只在部分通过时显示)
|
||||
const infoTable = computed(() => {
|
||||
if (!props.submission?.info?.data?.length) return []
|
||||
|
||||
const result = props.submission.result
|
||||
// AC、编译错误、运行时错误不显示测试用例表格
|
||||
if (
|
||||
result === SubmissionStatus.accepted ||
|
||||
result === SubmissionStatus.ast_check_failed ||
|
||||
result === SubmissionStatus.compile_error ||
|
||||
result === SubmissionStatus.runtime_error
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data = props.submission.info.data
|
||||
// 只有存在失败的测试用例时才显示
|
||||
return data.some((item) => item.result === 0) ? data : []
|
||||
})
|
||||
|
||||
// 测试用例表格列配置
|
||||
const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
|
||||
{ title: "测试用例", key: "test_case" },
|
||||
{
|
||||
title: "测试状态",
|
||||
key: "result",
|
||||
render: (row) => h(SubmissionResultTag, { result: row.result }),
|
||||
},
|
||||
{
|
||||
title: "占用内存",
|
||||
key: "memory",
|
||||
render: (row) => submissionMemoryFormat(row.memory),
|
||||
},
|
||||
{
|
||||
title: "执行耗时",
|
||||
key: "real_time",
|
||||
render: (row) => submissionTimeFormat(row.real_time),
|
||||
},
|
||||
{ title: "信号", key: "signal" },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="submission">
|
||||
<n-alert
|
||||
:type="JUDGE_STATUS[submission.result]['type']"
|
||||
:title="JUDGE_STATUS[submission.result]['title']"
|
||||
class="mb-3"
|
||||
/>
|
||||
<n-flex
|
||||
vertical
|
||||
v-if="
|
||||
msg ||
|
||||
infoTable.length ||
|
||||
submission.statistic_info?.ast_results?.length
|
||||
"
|
||||
>
|
||||
<n-card v-if="submission.statistic_info?.ast_results?.length" embedded>
|
||||
<n-flex vertical :size="8">
|
||||
<n-flex
|
||||
v-for="(rule, i) in submission.statistic_info.ast_results"
|
||||
:key="i"
|
||||
align="center"
|
||||
:size="6"
|
||||
>
|
||||
<n-icon
|
||||
:color="rule.passed ? theme.successColor : theme.errorColor"
|
||||
>
|
||||
<Icon :icon="rule.passed ? 'ph:check-bold' : 'ph:x-bold'" />
|
||||
</n-icon>
|
||||
<span>{{ rule.description }}</span>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
<n-card v-if="msg" embedded class="msg">{{ msg }}</n-card>
|
||||
<n-data-table
|
||||
v-if="infoTable.length"
|
||||
striped
|
||||
:data="infoTable"
|
||||
:columns="columns"
|
||||
/>
|
||||
</n-flex>
|
||||
|
||||
<!-- AI 提示区域 -->
|
||||
<template v-if="showAIHint">
|
||||
<n-card size="small" style="margin-top: 12px; max-width: 480px">
|
||||
<n-alert
|
||||
v-if="hintError"
|
||||
type="error"
|
||||
:title="hintError"
|
||||
class="mb-3"
|
||||
/>
|
||||
<n-button
|
||||
v-if="!hintContent && !hintLoading"
|
||||
type="primary"
|
||||
@click="fetchHint(submission.id)"
|
||||
>
|
||||
让 AI 分析我的代码
|
||||
</n-button>
|
||||
<n-spin v-else-if="hintLoading && !hintContent" size="small" />
|
||||
<MdPreview
|
||||
v-if="hintContent"
|
||||
:model-value="hintContent"
|
||||
preview-theme="vuepress"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
/>
|
||||
</n-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.msg {
|
||||
white-space: pre;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
275
apps/web/src/oj/problem/components/SubmitCode.vue
Normal file
275
apps/web/src/oj/problem/components/SubmitCode.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { storeToRefs } from "pinia"
|
||||
import {
|
||||
formatCode,
|
||||
getReaction,
|
||||
submitCode,
|
||||
updateProblemSetProgress,
|
||||
} from "oj/api"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useFireworks } from "oj/problem/composables/useFireworks"
|
||||
import { useSubmissionMonitor } from "oj/problem/composables/useSubmissionMonitor"
|
||||
import { LANGUAGE_FORMAT_VALUE, SubmissionStatus } from "utils/constants"
|
||||
import type { SubmitCodePayload } from "utils/types"
|
||||
import SubmissionResult from "./SubmissionResult.vue"
|
||||
import { getSubmitButtonState } from "./submitButtonState"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import {
|
||||
checkPythonSyntax,
|
||||
prefetchPythonSyntaxChecker,
|
||||
} from "oj/problem/utils/pythonSyntaxCheck"
|
||||
|
||||
// ==================== 异步组件 ====================
|
||||
const ProblemReaction = defineAsyncComponent(
|
||||
() => import("./ProblemReaction.vue"),
|
||||
)
|
||||
|
||||
// ==================== 基础状态 ====================
|
||||
const userStore = useUserStore()
|
||||
const codeStore = useCodeStore()
|
||||
const problemStore = useProblemStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
const route = useRoute()
|
||||
const contestID = (route.params.contestID as string) ?? ""
|
||||
const problemSetId = (route.params.problemSetId as string) ?? ""
|
||||
|
||||
const router = useRouter()
|
||||
const [commentPanel] = useToggle()
|
||||
const message = useMessage()
|
||||
|
||||
function closeCommentPanel() {
|
||||
commentPanel.value = false
|
||||
}
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
// ==================== 烟花效果 ====================
|
||||
const { celebrate } = useFireworks()
|
||||
|
||||
// ==================== 判题监控 ====================
|
||||
const { submission, judging, pending, submitting, startMonitoring } =
|
||||
useSubmissionMonitor()
|
||||
|
||||
const showResult = ref(false)
|
||||
const isFormatting = ref(false)
|
||||
const isSubmittingRequest = ref(false)
|
||||
|
||||
// ==================== Python 语法检测器预取 ====================
|
||||
// 选中 Python3 时就把 Skulpt 拉下来,避免点提交时才开始下载
|
||||
watch(
|
||||
() => codeStore.code.language,
|
||||
(language) => {
|
||||
if (language === "Python3") prefetchPythonSyntaxChecker()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// ==================== 提交冷却 ====================
|
||||
const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
|
||||
controls: true,
|
||||
immediate: false,
|
||||
})
|
||||
|
||||
// ==================== AC后显示评论框 ====================
|
||||
const { start: showCommentPanelDelayed } = useTimeoutFn(
|
||||
async () => {
|
||||
const res = await getReaction(problem.value!.id)
|
||||
if (res.data.mine === null) {
|
||||
commentPanel.value = true
|
||||
}
|
||||
},
|
||||
1500,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
const { start: goToProblemSetDelayed } = useTimeoutFn(
|
||||
() => {
|
||||
router.push({
|
||||
name: "problemset",
|
||||
params: {
|
||||
problemSetId: problemSetId,
|
||||
},
|
||||
})
|
||||
},
|
||||
1500,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
const buttonState = computed(() =>
|
||||
getSubmitButtonState({
|
||||
isAuthed: userStore.isAuthed,
|
||||
hasCode: codeStore.code.value.trim() !== "",
|
||||
isFormatting: isFormatting.value,
|
||||
isSubmitting: isSubmittingRequest.value || submitting.value,
|
||||
isJudging: judging.value || pending.value,
|
||||
isCooldown: isCooldown.value,
|
||||
}),
|
||||
)
|
||||
|
||||
// ==================== 提交函数 ====================
|
||||
async function submit() {
|
||||
if (buttonState.value.disabled) return
|
||||
|
||||
// 0. Python3 语法检测
|
||||
if (codeStore.code.language === "Python3") {
|
||||
const syntaxError = await checkPythonSyntax(codeStore.code.value)
|
||||
if (syntaxError) {
|
||||
message.warning(`第 ${syntaxError.line} 行存在语法错误,请修正后再提交`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 0.5 提交前自动格式化(Python3 用 ruff,C/C++ 用 clang-format,SQL 用 sqlparse)
|
||||
const formatLang = LANGUAGE_FORMAT_VALUE[codeStore.code.language]
|
||||
if (["python", "c", "cpp", "sql"].includes(formatLang)) {
|
||||
isFormatting.value = true
|
||||
try {
|
||||
const res = await formatCode({
|
||||
code: codeStore.code.value,
|
||||
language: formatLang,
|
||||
})
|
||||
codeStore.setCode(res.data.code)
|
||||
} catch (e: any) {
|
||||
if (e?.error === "format-error") {
|
||||
// 仅 Python3 会出现:代码本身存在语法错误
|
||||
message.warning(`代码格式化失败:${e.data},请检查代码后重试`)
|
||||
return
|
||||
}
|
||||
// server-error / 网络异常:格式化工具问题,静默降级,提交原代码
|
||||
} finally {
|
||||
isFormatting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 构建提交数据
|
||||
const data: SubmitCodePayload = {
|
||||
problem_id: problem.value!.id,
|
||||
language: codeStore.code.language,
|
||||
code: codeStore.code.value,
|
||||
}
|
||||
if (contestID) {
|
||||
data.contest_id = parseInt(contestID)
|
||||
}
|
||||
// 2. 提交代码到后端
|
||||
isSubmittingRequest.value = true
|
||||
try {
|
||||
const res = await submitCode(data)
|
||||
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`)
|
||||
|
||||
// 3. 启动冷却 + 监控
|
||||
startCooldown()
|
||||
startMonitoring(res.data.submission_id)
|
||||
showResult.value = true
|
||||
} finally {
|
||||
isSubmittingRequest.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 失败计数 ====================
|
||||
watch(
|
||||
() => submission.value?.result,
|
||||
(result) => {
|
||||
if (result === undefined || result === null) return
|
||||
if (
|
||||
result === SubmissionStatus.pending ||
|
||||
result === SubmissionStatus.judging ||
|
||||
result === SubmissionStatus.submitting
|
||||
)
|
||||
return
|
||||
if (
|
||||
result !== SubmissionStatus.accepted &&
|
||||
result !== SubmissionStatus.ast_check_failed
|
||||
) {
|
||||
problemStore.incrementFailCount()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ==================== AC庆祝效果 ====================
|
||||
watch(
|
||||
() => submission.value?.result,
|
||||
async (result) => {
|
||||
if (
|
||||
result !== SubmissionStatus.accepted &&
|
||||
result !== SubmissionStatus.ast_check_failed
|
||||
)
|
||||
return
|
||||
|
||||
// 1. 刷新题目状态
|
||||
problem.value!.my_status = 0
|
||||
|
||||
// 2. 创建ProblemSetSubmission记录,更新题单进度
|
||||
if (problemSetId) {
|
||||
await updateProblemSetProgress(
|
||||
Number(problemSetId),
|
||||
problem.value!.id,
|
||||
submission.value!.id,
|
||||
)
|
||||
}
|
||||
|
||||
if (result !== SubmissionStatus.accepted) return
|
||||
|
||||
// 3. 放烟花
|
||||
celebrate()
|
||||
|
||||
// 4. 显示评价框
|
||||
if (!contestID && !problemSetId) {
|
||||
showCommentPanelDelayed()
|
||||
}
|
||||
|
||||
if (problemSetId) {
|
||||
// 延迟回到题单页面
|
||||
goToProblemSetDelayed()
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 提交按钮 + 结果弹窗 -->
|
||||
<n-popover
|
||||
trigger="manual"
|
||||
placement="bottom-end"
|
||||
scrollable
|
||||
:show-arrow="false"
|
||||
style="max-height: 600px"
|
||||
:show="showResult"
|
||||
@clickoutside="showResult = false"
|
||||
>
|
||||
<template #trigger>
|
||||
<n-button
|
||||
:size="isDesktop ? 'medium' : 'small'"
|
||||
type="primary"
|
||||
:disabled="buttonState.disabled"
|
||||
@click="submit"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<Icon :icon="buttonState.icon" />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ buttonState.label }}
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<!-- 结果展示 -->
|
||||
<SubmissionResult :submission="submission" />
|
||||
</n-popover>
|
||||
|
||||
<!-- 评价弹窗 -->
|
||||
<n-modal
|
||||
preset="card"
|
||||
title="恭喜你成功提交,说说你对这道题的感受吧"
|
||||
:mask-closable="false"
|
||||
:closable="false"
|
||||
:close-on-esc="false"
|
||||
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
|
||||
v-model:show="commentPanel"
|
||||
>
|
||||
<ProblemReaction @submitted="closeCommentPanel" />
|
||||
</n-modal>
|
||||
</template>
|
||||
422
apps/web/src/oj/problem/components/SubmitFlowchart.vue
Normal file
422
apps/web/src/oj/problem/components/SubmitFlowchart.vue
Normal file
@@ -0,0 +1,422 @@
|
||||
<script lang="ts" setup>
|
||||
import { toRefs } from "vue"
|
||||
|
||||
// 工具函数
|
||||
import { atou, utoa } from "utils/functions"
|
||||
|
||||
// 组合式函数
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useMermaid } from "shared/composables/useMermaid"
|
||||
import { useMermaidConverter } from "../composables/useMermaidConverter"
|
||||
import {
|
||||
useFlowchartWebSocket,
|
||||
type FlowchartEvaluationUpdate,
|
||||
} from "shared/composables/websocket"
|
||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||
|
||||
// API 和状态管理
|
||||
import {
|
||||
getCurrentProblemFlowchartSubmission,
|
||||
getFlowchartSubmissionDetail,
|
||||
submitFlowchart,
|
||||
} from "oj/api"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
interface Rating {
|
||||
score: number
|
||||
grade: string
|
||||
}
|
||||
|
||||
interface Evaluation extends Rating {
|
||||
feedback: string
|
||||
suggestions: string
|
||||
criteria_details: {
|
||||
[key: string]: { score: number; max: number; comment: string }
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 组合式函数和响应式变量 ====================
|
||||
interface FlowchartEditorInstance {
|
||||
getFlowchartData: () => { nodes: unknown[]; edges: unknown[] }
|
||||
setFlowchartData: (data: { nodes: unknown[]; edges: unknown[] }) => void
|
||||
}
|
||||
|
||||
// 通过inject获取FlowchartEditor组件的引用
|
||||
const flowchartEditorRef =
|
||||
inject<Ref<FlowchartEditorInstance | null>>("flowchartEditorRef")
|
||||
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
||||
|
||||
// 基础组合式函数
|
||||
const message = useMessage()
|
||||
const problemStore = useProblemStore()
|
||||
const { problem } = toRefs(problemStore)
|
||||
const { isDesktop } = useBreakpoints()
|
||||
const myFlowchartStore = useMyFlowchartStore()
|
||||
const { convertToMermaid } = useMermaidConverter()
|
||||
const { renderError, renderFlowchart } = useMermaid()
|
||||
|
||||
// 状态管理
|
||||
const rendering = ref(false)
|
||||
const loading = ref(false)
|
||||
const latestRating = ref<Rating>({ score: 0, grade: "" })
|
||||
const modalRating = ref<Rating>({ score: 0, grade: "" })
|
||||
const submissionCount = ref(0)
|
||||
const myFlowchartZippedStr = ref("")
|
||||
const myMermaidCode = ref("")
|
||||
const showDetailModal = ref(false)
|
||||
const evaluation = ref<Evaluation>({
|
||||
score: 0,
|
||||
grade: "",
|
||||
feedback: "",
|
||||
suggestions: "",
|
||||
criteria_details: {},
|
||||
})
|
||||
const page = ref(1)
|
||||
const lastSubmittedMermaidCode = ref("")
|
||||
const suggestionLines = computed(() =>
|
||||
splitSuggestionLines(evaluation.value.suggestions),
|
||||
)
|
||||
|
||||
function splitSuggestionLines(suggestions?: string | null) {
|
||||
return suggestions
|
||||
? suggestions
|
||||
.split("\n")
|
||||
.map((suggestion) => suggestion.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
// ==================== WebSocket 相关函数 ====================
|
||||
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
|
||||
if (data.type === "flowchart_evaluation_completed") {
|
||||
loading.value = false
|
||||
const grade = data.grade || ""
|
||||
latestRating.value = { score: data.score || 0, grade }
|
||||
message.success(`流程图评分完成!得分: ${data.score}分 (${grade}级)`)
|
||||
if ((grade === "A" || grade === "S") && lastSubmittedMermaidCode.value) {
|
||||
myFlowchartStore.show(lastSubmittedMermaidCode.value)
|
||||
}
|
||||
} else if (data.type === "flowchart_evaluation_failed") {
|
||||
loading.value = false
|
||||
message.error(`流程图评分失败: ${data.error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 WebSocket 连接
|
||||
const { connect, disconnect, subscribe } = useFlowchartWebSocket(
|
||||
handleWebSocketMessage,
|
||||
)
|
||||
|
||||
// 订阅提交更新
|
||||
function subscribeToSubmission(submissionId: string) {
|
||||
subscribe(submissionId)
|
||||
}
|
||||
|
||||
// ==================== 提交相关函数 ====================
|
||||
// 提交流程图
|
||||
async function submitFlowchartData() {
|
||||
if (!flowchartEditorRef?.value) return
|
||||
|
||||
// 获取流程图的JSON数据
|
||||
const flowchartData = flowchartEditorRef.value.getFlowchartData()
|
||||
|
||||
if (!flowchartData?.nodes?.length || !flowchartData?.edges?.length) {
|
||||
message.error("流程图节点或边不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
const mermaidCode = convertToMermaid(flowchartData)
|
||||
lastSubmittedMermaidCode.value = mermaidCode
|
||||
const compressed = utoa(JSON.stringify(flowchartData))
|
||||
|
||||
loading.value = true
|
||||
latestRating.value = { score: 0, grade: "" }
|
||||
|
||||
try {
|
||||
const response = await submitFlowchart({
|
||||
problem_id: problem.value!.id,
|
||||
mermaid_code: mermaidCode,
|
||||
flowchart_data: {
|
||||
compressed: true,
|
||||
data: compressed,
|
||||
},
|
||||
})
|
||||
|
||||
// 获取提交ID并订阅更新
|
||||
const submissionId = response.data.submission_id
|
||||
|
||||
if (submissionId) {
|
||||
subscribeToSubmission(submissionId)
|
||||
}
|
||||
|
||||
message.success("流程图已提交,请耐心等待评分")
|
||||
} catch (error) {
|
||||
loading.value = false
|
||||
message.error("流程图提交失败")
|
||||
console.error("提交流程图失败:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交函数
|
||||
function submit() {
|
||||
submitFlowchartData()
|
||||
}
|
||||
|
||||
// ==================== 数据获取和处理函数 ====================
|
||||
|
||||
async function getCurrentSubmission() {
|
||||
if (!problem.value?.id) return
|
||||
const { data } = await getCurrentProblemFlowchartSubmission(problem.value.id)
|
||||
submissionCount.value = data.count
|
||||
latestRating.value = {
|
||||
score: data.score,
|
||||
grade: data.grade,
|
||||
}
|
||||
}
|
||||
|
||||
async function getSubmission(submissionPage = 0) {
|
||||
if (!problem.value?.id) return
|
||||
const { data } = await getFlowchartSubmissionDetail(
|
||||
problem.value.id,
|
||||
submissionPage,
|
||||
)
|
||||
submissionCount.value = data.count
|
||||
const submission = data.submission
|
||||
myFlowchartZippedStr.value = submission.flowchart_data.data
|
||||
myMermaidCode.value = submission.mermaid_code || ""
|
||||
modalRating.value = {
|
||||
score: submission.ai_score,
|
||||
grade: submission.ai_grade,
|
||||
}
|
||||
evaluation.value = {
|
||||
score: submission.ai_score,
|
||||
grade: submission.ai_grade,
|
||||
feedback: submission.ai_feedback,
|
||||
suggestions: submission.ai_suggestions,
|
||||
criteria_details: submission.ai_criteria_details,
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePage(val: number) {
|
||||
page.value = val
|
||||
rendering.value = true
|
||||
await getSubmission(val)
|
||||
// 等待 DOM 更新
|
||||
await nextTick()
|
||||
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
|
||||
rendering.value = false
|
||||
}
|
||||
|
||||
// ==================== 模态框相关函数 ====================
|
||||
async function openDetailModal() {
|
||||
showDetailModal.value = true
|
||||
rendering.value = true
|
||||
await getSubmission()
|
||||
page.value = submissionCount.value
|
||||
// 等待 DOM 更新,确保弹框已经渲染
|
||||
await nextTick()
|
||||
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
|
||||
rendering.value = false
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showDetailModal.value = false
|
||||
}
|
||||
|
||||
function loadToEditor() {
|
||||
if (myFlowchartZippedStr.value) {
|
||||
const str = atou(myFlowchartZippedStr.value)
|
||||
const json = JSON.parse(str)
|
||||
const processedData = {
|
||||
nodes: json.nodes || [],
|
||||
edges: json.edges || [],
|
||||
}
|
||||
if (flowchartEditorRef?.value) {
|
||||
flowchartEditorRef.value.setFlowchartData(processedData)
|
||||
}
|
||||
}
|
||||
closeModal()
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
const getGradeType = (grade: string) => {
|
||||
if (grade === "S") return "primary"
|
||||
if (grade === "A") return "info"
|
||||
if (grade === "B") return "warning"
|
||||
return "error"
|
||||
}
|
||||
|
||||
const getPercentType = (percent: number) => {
|
||||
if (percent >= 0.8) return "primary"
|
||||
else if (percent >= 0.6) return "info"
|
||||
else if (percent >= 0.4) return "warning"
|
||||
return "error"
|
||||
}
|
||||
|
||||
// ==================== 生命周期钩子 ====================
|
||||
onMounted(async () => {
|
||||
connect()
|
||||
await getCurrentSubmission()
|
||||
page.value = submissionCount.value
|
||||
const grade = latestRating.value.grade
|
||||
if ((grade === "A" || grade === "S") && submissionCount.value > 0) {
|
||||
await getSubmission(submissionCount.value)
|
||||
if (myMermaidCode.value) {
|
||||
myFlowchartStore.show(myMermaidCode.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时断开连接
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 主要操作区域 -->
|
||||
<n-flex align="center">
|
||||
<!-- 提交按钮 -->
|
||||
<n-button
|
||||
:size="isDesktop ? 'medium' : 'small'"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
@click="submit"
|
||||
>
|
||||
{{ loading ? "AI 点评中..." : "提交流程图" }}
|
||||
</n-button>
|
||||
|
||||
<!-- 评分结果按钮 -->
|
||||
<n-button
|
||||
secondary
|
||||
v-if="latestRating.grade"
|
||||
@click="openDetailModal"
|
||||
:type="getGradeType(latestRating.grade)"
|
||||
>
|
||||
{{ latestRating.score }}分 {{ latestRating.grade }}级
|
||||
</n-button>
|
||||
|
||||
<!-- 流程图评分详情模态框 -->
|
||||
<n-modal v-model:show="showDetailModal" preset="card" style="width: 1000px">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<n-text>流程图评分详情</n-text>
|
||||
<n-text :type="getGradeType(modalRating.grade)">
|
||||
{{ modalRating.score }}分 {{ modalRating.grade }}级
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</template>
|
||||
<n-grid :cols="5" :x-gap="16">
|
||||
<!-- 左侧:流程图预览区域 -->
|
||||
<n-gi :span="3">
|
||||
<div class="flowchart">
|
||||
<n-spin :show="rendering">
|
||||
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
||||
{{ renderError }}
|
||||
</n-alert>
|
||||
<div class="flowchart" v-else ref="mermaidContainer"></div>
|
||||
</n-spin>
|
||||
</div>
|
||||
<!-- 加载到编辑器按钮 -->
|
||||
<n-flex style="margin-top: 16px" justify="center">
|
||||
<n-button @click="loadToEditor" type="primary">
|
||||
加载到流程图编辑器
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
|
||||
<!-- 右侧:评分详情区域 -->
|
||||
<n-gi :span="2" style="max-height: 550px; overflow: auto">
|
||||
<!-- AI反馈 -->
|
||||
<n-card
|
||||
v-if="evaluation.feedback"
|
||||
size="small"
|
||||
title="AI反馈"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<n-text>{{ evaluation.feedback }}</n-text>
|
||||
</n-card>
|
||||
|
||||
<!-- 改进建议 -->
|
||||
<n-card
|
||||
v-if="suggestionLines.length"
|
||||
size="small"
|
||||
title="改进建议"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<n-flex vertical :size="6">
|
||||
<n-text
|
||||
v-for="(suggestion, index) in suggestionLines"
|
||||
:key="`${index}-${suggestion}`"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
|
||||
<!-- 详细评分 -->
|
||||
<n-card
|
||||
v-if="evaluation.criteria_details"
|
||||
size="small"
|
||||
title="详细评分"
|
||||
>
|
||||
<div
|
||||
v-for="(detail, key) in evaluation.criteria_details"
|
||||
:key="key"
|
||||
style="margin-bottom: 12px"
|
||||
>
|
||||
<!-- 评分项标题和分数 -->
|
||||
<n-flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style="margin-bottom: 4px"
|
||||
>
|
||||
<n-text strong>{{ key }}</n-text>
|
||||
<n-tag
|
||||
:type="getPercentType(detail.score / detail.max)"
|
||||
size="small"
|
||||
round
|
||||
>
|
||||
{{ detail.score || 0 }}分 / {{ detail.max }}分
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<!-- 评分项详细说明 -->
|
||||
<n-text v-if="detail.comment" depth="3" style="font-size: 12px">
|
||||
{{ detail.comment }}
|
||||
</n-text>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- 分页组件 -->
|
||||
<n-flex
|
||||
justify="center"
|
||||
style="margin-top: 24px"
|
||||
v-if="submissionCount > 1"
|
||||
>
|
||||
<n-pagination
|
||||
v-model:page="page"
|
||||
:page-count="submissionCount"
|
||||
@update-page="updatePage"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</n-flex>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* ==================== 流程图样式 ==================== */
|
||||
.flowchart {
|
||||
height: 500px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 确保 SVG 图表占满容器 */
|
||||
:deep(.flowchart > svg) {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
53
apps/web/src/oj/problem/components/submitButtonState.ts
Normal file
53
apps/web/src/oj/problem/components/submitButtonState.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export interface SubmitButtonStateInput {
|
||||
isAuthed: boolean
|
||||
hasCode: boolean
|
||||
isFormatting: boolean
|
||||
isSubmitting: boolean
|
||||
isJudging: boolean
|
||||
isCooldown: boolean
|
||||
}
|
||||
|
||||
export interface SubmitButtonState {
|
||||
disabled: boolean
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export function getSubmitButtonState({
|
||||
isAuthed,
|
||||
hasCode,
|
||||
isFormatting,
|
||||
isSubmitting,
|
||||
isJudging,
|
||||
isCooldown,
|
||||
}: SubmitButtonStateInput): SubmitButtonState {
|
||||
const disabled =
|
||||
!isAuthed ||
|
||||
!hasCode ||
|
||||
isFormatting ||
|
||||
isSubmitting ||
|
||||
isJudging ||
|
||||
isCooldown
|
||||
|
||||
let label = "提交代码"
|
||||
if (!isAuthed) {
|
||||
label = "请先登录"
|
||||
} else if (isFormatting) {
|
||||
label = "格式化中"
|
||||
} else if (isSubmitting) {
|
||||
label = "正在提交"
|
||||
} else if (isJudging) {
|
||||
label = "正在评分"
|
||||
} else if (isCooldown) {
|
||||
label = "正在冷却"
|
||||
}
|
||||
|
||||
const icon =
|
||||
isFormatting || isSubmitting || isJudging
|
||||
? "eos-icons:loading"
|
||||
: isCooldown
|
||||
? "ph:lightbulb-fill"
|
||||
: "ph:play-fill"
|
||||
|
||||
return { disabled, label, icon }
|
||||
}
|
||||
391
apps/web/src/oj/problem/composables/useFireworks.ts
Normal file
391
apps/web/src/oj/problem/composables/useFireworks.ts
Normal file
File diff suppressed because one or more lines are too long
125
apps/web/src/oj/problem/composables/useMermaidConverter.ts
Normal file
125
apps/web/src/oj/problem/composables/useMermaidConverter.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 将流程图JSON数据转换为Mermaid格式
|
||||
*/
|
||||
export function useMermaidConverter() {
|
||||
const convertToMermaid = (flowchartData: any) => {
|
||||
const { nodes, edges } = flowchartData
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return "graph TD\n A[空流程图]"
|
||||
}
|
||||
|
||||
let mermaid = "graph TD\n"
|
||||
|
||||
// Build safe ID mapping to prevent Mermaid syntax errors from special characters
|
||||
const idMap = new Map<string, string>()
|
||||
nodes.forEach((node: any, index: number) => {
|
||||
idMap.set(node.id, `node_${index}`)
|
||||
})
|
||||
const safeId = (id: string) =>
|
||||
idMap.get(id) || id.replace(/[^a-zA-Z0-9_]/g, "_")
|
||||
|
||||
// 处理节点 - 根据原始类型和自定义标签
|
||||
nodes.forEach((node: any) => {
|
||||
const nodeId = safeId(node.id)
|
||||
const label = node.data?.customLabel || node.data?.label || "节点"
|
||||
const originalType = node.data?.originalType || node.type
|
||||
|
||||
// 根据节点原始类型确定Mermaid语法
|
||||
switch (originalType) {
|
||||
case "start":
|
||||
mermaid += ` ${nodeId}(("${label}"))\n`
|
||||
break
|
||||
case "end":
|
||||
mermaid += ` ${nodeId}(("${label}"))\n`
|
||||
break
|
||||
case "input":
|
||||
// 输入框使用平行四边形
|
||||
mermaid += ` ${nodeId}[/"${label}"/]\n`
|
||||
break
|
||||
case "output":
|
||||
// 输出框使用平行四边形
|
||||
mermaid += ` ${nodeId}[/"${label}"/]\n`
|
||||
break
|
||||
case "default":
|
||||
mermaid += ` ${nodeId}["${label}"]\n`
|
||||
break
|
||||
case "decision":
|
||||
mermaid += ` ${nodeId}{"${label}"}\n`
|
||||
break
|
||||
case "loop":
|
||||
// 循环使用菱形
|
||||
mermaid += ` ${nodeId}{"${label}"}\n`
|
||||
break
|
||||
default:
|
||||
mermaid += ` ${nodeId}["${label}"]\n`
|
||||
}
|
||||
})
|
||||
|
||||
// 处理边
|
||||
edges.forEach((edge: any) => {
|
||||
const source = safeId(edge.source)
|
||||
const target = safeId(edge.target)
|
||||
const label = edge.label ?? ""
|
||||
|
||||
if (label && label.trim() !== "") {
|
||||
mermaid += ` ${source} -->|"${label}"| ${target}\n`
|
||||
} else {
|
||||
mermaid += ` ${source} --> ${target}\n`
|
||||
}
|
||||
})
|
||||
|
||||
// 添加样式定义来区分不同类型的节点
|
||||
mermaid += "\n"
|
||||
mermaid +=
|
||||
" classDef startNode fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#0f172a\n"
|
||||
mermaid +=
|
||||
" classDef endNode fill:#fee2e2,stroke:#dc2626,stroke-width:2px,color:#0f172a\n"
|
||||
mermaid +=
|
||||
" classDef input fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#0f172a\n"
|
||||
mermaid +=
|
||||
" classDef output fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#0f172a\n"
|
||||
mermaid +=
|
||||
" classDef process fill:#f0f9ff,stroke:#0284c7,stroke-width:2px,color:#0f172a\n"
|
||||
mermaid +=
|
||||
" classDef decision fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#0f172a\n"
|
||||
mermaid +=
|
||||
" classDef loop fill:#fae8ff,stroke:#c026d3,stroke-width:2px,color:#0f172a\n"
|
||||
mermaid += "\n"
|
||||
|
||||
// 为节点应用样式
|
||||
nodes.forEach((node: any) => {
|
||||
const nodeId = safeId(node.id)
|
||||
const originalType = node.data?.originalType || node.type
|
||||
|
||||
switch (originalType) {
|
||||
case "start":
|
||||
mermaid += ` class ${nodeId} startNode\n`
|
||||
break
|
||||
case "end":
|
||||
mermaid += ` class ${nodeId} endNode\n`
|
||||
break
|
||||
case "input":
|
||||
mermaid += ` class ${nodeId} input\n`
|
||||
break
|
||||
case "output":
|
||||
mermaid += ` class ${nodeId} output\n`
|
||||
break
|
||||
case "decision":
|
||||
mermaid += ` class ${nodeId} decision\n`
|
||||
break
|
||||
case "loop":
|
||||
mermaid += ` class ${nodeId} loop\n`
|
||||
break
|
||||
default:
|
||||
mermaid += ` class ${nodeId} process\n`
|
||||
}
|
||||
})
|
||||
|
||||
return mermaid
|
||||
}
|
||||
|
||||
return {
|
||||
convertToMermaid,
|
||||
}
|
||||
}
|
||||
190
apps/web/src/oj/problem/composables/useSubmissionMonitor.ts
Normal file
190
apps/web/src/oj/problem/composables/useSubmissionMonitor.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { ref, computed, watch, onUnmounted } from "vue"
|
||||
import { useIntervalFn, useTimeoutFn } from "@vueuse/core"
|
||||
import { getSubmission } from "oj/api"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
import type { PendingAchievement, Submission } from "utils/types"
|
||||
import { useAchievementStore } from "shared/store/achievement"
|
||||
import {
|
||||
useSubmissionWebSocket,
|
||||
type SubmissionUpdate,
|
||||
} from "shared/composables/websocket"
|
||||
|
||||
/**
|
||||
* 判题监控 Composable
|
||||
* 负责通过 WebSocket + 轮询双保险机制监控判题结果
|
||||
*/
|
||||
export function useSubmissionMonitor() {
|
||||
// ==================== 状态 ====================
|
||||
const submissionId = ref("")
|
||||
const submission = ref<Submission>()
|
||||
|
||||
// ==================== 轮询机制 ====================
|
||||
const { pause: pausePolling, resume: resumePolling } = useIntervalFn(
|
||||
async () => {
|
||||
if (!submissionId.value) return
|
||||
|
||||
try {
|
||||
const res = await getSubmission(submissionId.value)
|
||||
submission.value = res.data
|
||||
|
||||
const result = res.data.result
|
||||
// 判题完成,停止轮询
|
||||
if (
|
||||
result !== SubmissionStatus.judging &&
|
||||
result !== SubmissionStatus.pending
|
||||
) {
|
||||
pausePolling()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[SubmissionMonitor] 轮询失败:", error)
|
||||
pausePolling()
|
||||
}
|
||||
},
|
||||
2000,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
// ==================== WebSocket 处理 ====================
|
||||
const handleSubmissionUpdate = (data: SubmissionUpdate) => {
|
||||
// push_to_user 复用了 submission_update 这个 channel handler,
|
||||
// 其他类型的消息会走同一条 WebSocket 帧进来,必须先分流
|
||||
const frame = data as unknown as {
|
||||
type: string
|
||||
achievements?: PendingAchievement[]
|
||||
}
|
||||
if (frame.type === "achievement_unlocked") {
|
||||
useAchievementStore().enqueue(frame.achievements ?? [])
|
||||
return
|
||||
}
|
||||
if (frame.type !== "submission_update") {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[SubmissionMonitor] 收到WebSocket更新:", data)
|
||||
|
||||
if (data.submission_id !== submissionId.value) {
|
||||
console.log("[SubmissionMonitor] 提交ID不匹配,忽略")
|
||||
return
|
||||
}
|
||||
|
||||
if (!submission.value) {
|
||||
submission.value = {} as Submission
|
||||
}
|
||||
|
||||
submission.value.result = data.result as Submission["result"]
|
||||
|
||||
// 判题完成或出错,获取完整详情
|
||||
if (data.status === "finished" || data.status === "error") {
|
||||
console.log(
|
||||
`[SubmissionMonitor] 判题${data.status === "finished" ? "完成" : "出错"}`,
|
||||
)
|
||||
|
||||
// 停止轮询(WebSocket已成功)
|
||||
pausePolling()
|
||||
|
||||
getSubmission(submissionId.value).then((res) => {
|
||||
submission.value = res.data
|
||||
// 15分钟无新提交则断开WebSocket(节省资源)
|
||||
scheduleDisconnect(15 * 60 * 1000)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 WebSocket
|
||||
const {
|
||||
connect,
|
||||
subscribe,
|
||||
scheduleDisconnect,
|
||||
cancelScheduledDisconnect,
|
||||
status: wsStatus,
|
||||
} = useSubmissionWebSocket(handleSubmissionUpdate)
|
||||
|
||||
// ==================== 轮询保底启动 ====================
|
||||
const { start: startPollingFallback } = useTimeoutFn(
|
||||
() => {
|
||||
if (
|
||||
submission.value &&
|
||||
(submission.value.result === SubmissionStatus.judging ||
|
||||
submission.value.result === SubmissionStatus.pending ||
|
||||
submission.value.result === 9) // 9 = submitting
|
||||
) {
|
||||
console.log("[SubmissionMonitor] WebSocket未及时响应,启动轮询保底")
|
||||
resumePolling()
|
||||
}
|
||||
},
|
||||
5000,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
// ==================== 启动监控 ====================
|
||||
const startMonitoring = (id: string) => {
|
||||
submissionId.value = id
|
||||
submission.value = { id, result: 9 } as Submission // 9 = submitting
|
||||
|
||||
// 取消之前的断开计划
|
||||
cancelScheduledDisconnect()
|
||||
|
||||
// 如果WebSocket未连接,先连接
|
||||
if (wsStatus.value !== "connected") {
|
||||
console.log("[SubmissionMonitor] 启动WebSocket连接...")
|
||||
connect()
|
||||
}
|
||||
|
||||
// 等待WebSocket连接并订阅
|
||||
let unwatch: (() => void) | null = null
|
||||
unwatch = watch(
|
||||
wsStatus,
|
||||
(status) => {
|
||||
if (status === "connected") {
|
||||
console.log("[SubmissionMonitor] WebSocket已连接,订阅提交:", id)
|
||||
subscribe(id)
|
||||
if (unwatch) {
|
||||
unwatch() // 订阅成功后停止监听
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 5秒后启动轮询保底(防止WebSocket失败)
|
||||
startPollingFallback()
|
||||
}
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
const judging = computed(
|
||||
() => submission.value?.result === SubmissionStatus.judging,
|
||||
)
|
||||
|
||||
const pending = computed(
|
||||
() => submission.value?.result === SubmissionStatus.pending,
|
||||
)
|
||||
|
||||
const submitting = computed(
|
||||
() => submission.value?.result === SubmissionStatus.submitting,
|
||||
)
|
||||
|
||||
const isProcessing = computed(() => {
|
||||
return judging.value || pending.value || submitting.value
|
||||
})
|
||||
|
||||
// ==================== 清理 ====================
|
||||
onUnmounted(() => {
|
||||
pausePolling()
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
submissionId,
|
||||
submission,
|
||||
|
||||
// 计算属性
|
||||
judging,
|
||||
pending,
|
||||
submitting,
|
||||
isProcessing,
|
||||
|
||||
// 方法
|
||||
startMonitoring,
|
||||
pausePolling,
|
||||
}
|
||||
}
|
||||
284
apps/web/src/oj/problem/detail.vue
Normal file
284
apps/web/src/oj/problem/detail.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import { getProblem } from "oj/api"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useScreenModeStore } from "shared/store/screenMode"
|
||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||
|
||||
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk
|
||||
const loadProblemEditor = () => import("./components/ProblemEditor.vue")
|
||||
const loadContestEditor = () => import("./components/ContestEditor.vue")
|
||||
const ProblemEditor = defineAsyncComponent(loadProblemEditor)
|
||||
const ContestEditor = defineAsyncComponent(loadContestEditor)
|
||||
const EditorForTest = defineAsyncComponent(
|
||||
() => import("./components/EditorForTest.vue"),
|
||||
)
|
||||
const ProblemContent = defineAsyncComponent(
|
||||
() => import("./components/ProblemContent.vue"),
|
||||
)
|
||||
const ProblemInfo = defineAsyncComponent(
|
||||
() => import("./components/ProblemInfo.vue"),
|
||||
)
|
||||
const ProblemSubmission = defineAsyncComponent(
|
||||
() => import("./components/ProblemSubmission.vue"),
|
||||
)
|
||||
const ProblemReaction = defineAsyncComponent(
|
||||
() => import("./components/ProblemReaction.vue"),
|
||||
)
|
||||
const ProblemFlowchart = defineAsyncComponent(
|
||||
() => import("./components/ProblemFlowchart.vue"),
|
||||
)
|
||||
const MyFlowchartTab = defineAsyncComponent(
|
||||
() => import("./components/MyFlowchartTab.vue"),
|
||||
)
|
||||
|
||||
interface Props {
|
||||
problemID: string
|
||||
contestID?: string
|
||||
problemSetId?: string
|
||||
}
|
||||
|
||||
const { problemID, contestID = "", problemSetId = "" } = defineProps<Props>()
|
||||
|
||||
const errMsg = ref("无数据")
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const problemStore = useProblemStore()
|
||||
const screenModeStore = useScreenModeStore()
|
||||
const myFlowchartStore = useMyFlowchartStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
const { shouldShowProblem } = storeToRefs(screenModeStore)
|
||||
|
||||
const { isMobile, isDesktop } = useBreakpoints()
|
||||
|
||||
const tabOptions = computed(() => {
|
||||
const options: string[] = ["content"]
|
||||
if (problem.value?.show_flowchart) {
|
||||
options.push("flowchart")
|
||||
}
|
||||
|
||||
if (isMobile.value) {
|
||||
options.push("editor")
|
||||
}
|
||||
options.push("info")
|
||||
if (!contestID) {
|
||||
options.push("comment")
|
||||
}
|
||||
if (myFlowchartStore.showing) {
|
||||
options.push("my-flowchart")
|
||||
}
|
||||
options.push("submission")
|
||||
return options
|
||||
})
|
||||
|
||||
const currentTab = ref("content")
|
||||
|
||||
const inProblem = computed(() => route.name === "problem")
|
||||
|
||||
watch(
|
||||
[() => route.query.tab, () => tabOptions.value],
|
||||
([rawTab]) => {
|
||||
const tabs = tabOptions.value
|
||||
const fallback = tabs[0] ?? "content"
|
||||
currentTab.value = tabs.includes(rawTab as string)
|
||||
? (rawTab as string)
|
||||
: fallback
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(currentTab, (tab) => {
|
||||
if (!tabOptions.value.includes(tab) || route.query.tab === tab) return
|
||||
router.replace({
|
||||
query: { ...route.query, tab },
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => myFlowchartStore.showing,
|
||||
(showing) => {
|
||||
if (showing) currentTab.value = "my-flowchart"
|
||||
},
|
||||
)
|
||||
|
||||
async function init() {
|
||||
screenModeStore.resetScreenMode()
|
||||
// 并行预取右侧编辑器 chunk(CodeMirror ~370K+),
|
||||
// 避免等 getProblem 返回后才串行下载,编辑器才迟迟出现
|
||||
;(inProblem.value ? loadProblemEditor : loadContestEditor)()
|
||||
try {
|
||||
const res = await getProblem(problemID, contestID)
|
||||
problem.value = res.data
|
||||
} catch (err: any) {
|
||||
problem.value = null
|
||||
if (err.data === "Contest has not started yet.") {
|
||||
errMsg.value = "比赛还没有开始"
|
||||
}
|
||||
}
|
||||
}
|
||||
onMounted(init)
|
||||
watch(() => problemID, init)
|
||||
onBeforeUnmount(() => {
|
||||
problem.value = null
|
||||
errMsg.value = "无数据"
|
||||
screenModeStore.resetScreenMode()
|
||||
myFlowchartStore.hide()
|
||||
})
|
||||
|
||||
watch(isMobile, (value) => {
|
||||
if (value) screenModeStore.resetScreenMode()
|
||||
})
|
||||
|
||||
// SQL 题不支持"自测"模式(外部代码运行器无法执行 SQL),切到该屏时自动跳到下一模式
|
||||
watch(
|
||||
() => screenModeStore.isCodeOnlyMode,
|
||||
(codeOnly) => {
|
||||
if (codeOnly && problem.value?.languages.includes("SQL")) {
|
||||
screenModeStore.switchScreenMode()
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="problem">
|
||||
<n-split
|
||||
v-if="isDesktop && screenModeStore.isBothMode"
|
||||
direction="horizontal"
|
||||
:default-size="0.43"
|
||||
:min="0.2"
|
||||
:max="0.8"
|
||||
style="height: calc(100vh - 92px)"
|
||||
>
|
||||
<template #1>
|
||||
<n-scrollbar style="height: 100%">
|
||||
<n-tabs v-model:value="currentTab" type="segment">
|
||||
<n-tab-pane name="content" tab="题目描述">
|
||||
<ProblemContent />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="problem.show_flowchart && problem.mermaid_code"
|
||||
name="flowchart"
|
||||
tab="流程图表"
|
||||
>
|
||||
<ProblemFlowchart />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId">
|
||||
<ProblemInfo />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="!contestID"
|
||||
name="comment"
|
||||
tab="题目点评"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<ProblemReaction />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="myFlowchartStore.showing"
|
||||
name="my-flowchart"
|
||||
tab="我的流程图"
|
||||
>
|
||||
<MyFlowchartTab />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
name="submission"
|
||||
tab="我的提交"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<ProblemSubmission />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-scrollbar>
|
||||
</template>
|
||||
<template #2>
|
||||
<component :is="inProblem ? ProblemEditor : ContestEditor" />
|
||||
</template>
|
||||
</n-split>
|
||||
|
||||
<!-- Desktop: code only mode -->
|
||||
<template v-else-if="isDesktop && screenModeStore.isCodeOnlyMode">
|
||||
<EditorForTest />
|
||||
</template>
|
||||
|
||||
<!-- Desktop: problem only mode -->
|
||||
<template v-else-if="isDesktop && shouldShowProblem">
|
||||
<n-scrollbar style="max-height: calc(100vh - 92px)">
|
||||
<n-tabs v-model:value="currentTab" type="segment">
|
||||
<n-tab-pane name="content" tab="题目描述">
|
||||
<ProblemContent />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="problem.show_flowchart && problem.mermaid_code"
|
||||
name="flowchart"
|
||||
tab="流程图表"
|
||||
>
|
||||
<ProblemFlowchart />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId">
|
||||
<ProblemInfo />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="!contestID"
|
||||
name="comment"
|
||||
tab="题目点评"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<ProblemReaction />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="myFlowchartStore.showing"
|
||||
name="my-flowchart"
|
||||
tab="我的流程图"
|
||||
>
|
||||
<MyFlowchartTab />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
name="submission"
|
||||
tab="我的提交"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<ProblemSubmission />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-scrollbar>
|
||||
</template>
|
||||
|
||||
<!-- Mobile -->
|
||||
<n-tabs v-else v-model:value="currentTab" type="segment">
|
||||
<n-tab-pane name="content" tab="描述">
|
||||
<ProblemContent />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="problem.show_flowchart" name="flowchart" tab="流程">
|
||||
<ProblemFlowchart />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="editor" tab="代码">
|
||||
<component :is="inProblem ? ProblemEditor : ContestEditor" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="info" tab="统计" :disabled="!!problemSetId">
|
||||
<ProblemInfo />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="!contestID"
|
||||
name="comment"
|
||||
tab="点评"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<ProblemReaction />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="myFlowchartStore.showing"
|
||||
name="my-flowchart"
|
||||
tab="我的流程图"
|
||||
>
|
||||
<MyFlowchartTab />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="submission" tab="提交" :disabled="!!problemSetId">
|
||||
<ProblemSubmission />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
<n-empty v-else :description="errMsg"></n-empty>
|
||||
</template>
|
||||
342
apps/web/src/oj/problem/list.vue
Normal file
342
apps/web/src/oj/problem/list.vue
Normal file
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { NFlex, NTag } from "naive-ui"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import { getProblemList, getRandomProblemID } from "oj/api"
|
||||
import { getTagColor } from "utils/functions"
|
||||
import type { ProblemFiltered } from "utils/types"
|
||||
import { getProblemTagList } from "shared/api"
|
||||
import Hitokoto from "shared/components/Hitokoto.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
import ProblemStatus from "./components/ProblemStatus.vue"
|
||||
import AuthorSelect from "shared/components/AuthorSelect.vue"
|
||||
import ProblemListTitle from "./components/ProblemListTitle.vue"
|
||||
|
||||
interface Tag {
|
||||
id: number
|
||||
name: string
|
||||
checked: boolean
|
||||
}
|
||||
|
||||
interface ProblemQuery {
|
||||
keyword: string
|
||||
difficulty: string
|
||||
tag: string
|
||||
author: string
|
||||
sort: string
|
||||
}
|
||||
|
||||
const difficultyOptions = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "简单", value: "Low" },
|
||||
{ label: "中等", value: "Mid" },
|
||||
{ label: "困难", value: "High" },
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
{ label: "最新创建", value: "" },
|
||||
{ label: "最早创建", value: "create_time" },
|
||||
{ label: "最多提交", value: "-submission_number" },
|
||||
{ label: "最少提交", value: "submission_number" },
|
||||
{ label: "最多通过", value: "-accepted_number" },
|
||||
{ label: "最少通过", value: "accepted_number" },
|
||||
{ label: "画流程图", value: "flowchart" },
|
||||
{ label: "语法检查", value: "ast" },
|
||||
]
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const problems = ref<ProblemFiltered[]>([])
|
||||
const total = ref(0)
|
||||
const tags = ref<Tag[]>([])
|
||||
const [showTag, toggleShowTag] = useToggle(isDesktop.value)
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<ProblemQuery>({
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
difficulty: useRouteQuery("difficulty", "").value,
|
||||
tag: useRouteQuery("tag", "").value,
|
||||
author: useRouteQuery("author", "").value,
|
||||
sort: useRouteQuery("sort", "").value,
|
||||
})
|
||||
|
||||
async function listProblems() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getProblemList(offset, query.limit, {
|
||||
keyword: query.keyword,
|
||||
tag: query.tag,
|
||||
difficulty: query.difficulty,
|
||||
author: query.author,
|
||||
sort: query.sort,
|
||||
})
|
||||
total.value = res.total
|
||||
problems.value = res.results
|
||||
}
|
||||
|
||||
async function listTags() {
|
||||
const res = await getProblemTagList()
|
||||
tags.value = res.data.map((r: Omit<Tag, "checked">) => ({
|
||||
...r,
|
||||
checked: query.tag === r.name,
|
||||
}))
|
||||
}
|
||||
|
||||
function chooseTag(tag: Tag) {
|
||||
query.tag = tag.checked ? "" : tag.name
|
||||
tags.value = tags.value.map((t) => {
|
||||
if (t.id === tag.id) {
|
||||
t.checked = !t.checked
|
||||
} else {
|
||||
t.checked = false
|
||||
}
|
||||
return t
|
||||
})
|
||||
}
|
||||
|
||||
async function getRandom() {
|
||||
const res = await getRandomProblemID()
|
||||
router.push("/problem/" + res.data)
|
||||
}
|
||||
|
||||
// 监听搜索关键词变化(防抖)
|
||||
watchDebounced(() => query.keyword, listProblems, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
() => [
|
||||
query.tag,
|
||||
query.difficulty,
|
||||
query.limit,
|
||||
query.page,
|
||||
query.author,
|
||||
query.sort,
|
||||
],
|
||||
listProblems,
|
||||
)
|
||||
|
||||
// 监听标签变化,更新标签选中状态
|
||||
watch(
|
||||
() => query.tag,
|
||||
() => {
|
||||
tags.value = tags.value.map((r: Omit<Tag, "checked">) => ({
|
||||
...r,
|
||||
checked: query.tag === r.name,
|
||||
}))
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => userStore.isFinished && userStore.isAuthed,
|
||||
(isAuthenticatedAndFinished) => {
|
||||
if (isAuthenticatedAndFinished) {
|
||||
listProblems()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
listProblems()
|
||||
listTags()
|
||||
})
|
||||
|
||||
const baseColumns: DataTableColumn<ProblemFiltered>[] = [
|
||||
{
|
||||
title: renderTableTitle("状态", "streamline-emojis:high-voltage"),
|
||||
key: "status",
|
||||
width: 80,
|
||||
align: "center",
|
||||
render: (row) => h(ProblemStatus, { status: row.status }),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"编号",
|
||||
"streamline-ultimate-color:board-game-dice-1",
|
||||
),
|
||||
key: "_id",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"题目",
|
||||
"streamline-ultimate-color:fruit-watermelon",
|
||||
),
|
||||
key: "title",
|
||||
minWidth: 200,
|
||||
render: (row) => h(ProblemListTitle, { problem: row }),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("难度", "streamline-emojis:lady-beetle"),
|
||||
key: "difficulty",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(NTag, { type: getTagColor(row.difficulty) }, () => row.difficulty),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("标签", "streamline-ultimate-color:attachment"),
|
||||
key: "tags",
|
||||
width: 260,
|
||||
render: (row) =>
|
||||
h(NFlex, () => row.tags.map((t) => h(NTag, { key: t }, () => t))),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("出题者", "streamline-emojis:man-raising-hand-2"),
|
||||
key: "author",
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("提交数", "streamline-ultimate-color:paper-write"),
|
||||
key: "submission",
|
||||
align: "center",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("通过率", "streamline-emojis:victory-hand-2"),
|
||||
key: "rate",
|
||||
width: 100,
|
||||
align: "center",
|
||||
},
|
||||
]
|
||||
|
||||
const columns = computed(() =>
|
||||
userStore.isAuthed
|
||||
? baseColumns
|
||||
: baseColumns.filter((c: any) => c.key !== "status"),
|
||||
)
|
||||
|
||||
function rowProps(row: ProblemFiltered) {
|
||||
return {
|
||||
style: "cursor: pointer",
|
||||
onClick() {
|
||||
router.push("/problem/" + row._id)
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical size="large">
|
||||
<div class="problem-list-toolbar">
|
||||
<n-space>
|
||||
<n-form :show-feedback="false" inline label-placement="left">
|
||||
<n-form-item label="难度">
|
||||
<n-select
|
||||
style="width: 80px"
|
||||
v-model:value="query.difficulty"
|
||||
:options="difficultyOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="出题者">
|
||||
<AuthorSelect v-model:value="query.author" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form :show-feedback="false" inline label-placement="left">
|
||||
<n-form-item label="排序">
|
||||
<n-select
|
||||
style="width: 120px"
|
||||
v-model:value="query.sort"
|
||||
:options="sortOptions"
|
||||
:dropdown-style="{ maxHeight: 'unset' }"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-input
|
||||
clearable
|
||||
style="width: 160px"
|
||||
v-model:value="query.keyword"
|
||||
placeholder="题号或标题"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form :show-feedback="false" inline label-placement="left">
|
||||
<n-form-item>
|
||||
<n-button @click="clearQuery" quaternary>重置</n-button>
|
||||
</n-form-item>
|
||||
<!-- <n-form-item>
|
||||
<n-button @click="getRandom" quaternary>随机</n-button>
|
||||
</n-form-item> -->
|
||||
<n-form-item>
|
||||
<n-button
|
||||
@click="toggleShowTag()"
|
||||
quaternary
|
||||
icon-placement="right"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon v-if="showTag" icon="ph:caret-down"></Icon>
|
||||
<Icon v-else icon="ph:caret-up"></Icon>
|
||||
</template>
|
||||
标签
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-space>
|
||||
<Hitokoto v-if="isDesktop" class="problem-list-hitokoto" />
|
||||
</div>
|
||||
<n-collapse-transition :show="showTag">
|
||||
<n-flex>
|
||||
<n-tag
|
||||
v-for="tag in tags"
|
||||
:closable="tag.checked"
|
||||
@close="chooseTag(tag)"
|
||||
@click="chooseTag(tag)"
|
||||
:key="tag.id"
|
||||
:type="tag.checked ? 'success' : 'default'"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
</n-collapse-transition>
|
||||
<n-data-table
|
||||
:bordered="false"
|
||||
:data="problems"
|
||||
:columns="columns"
|
||||
:row-props="rowProps"
|
||||
/>
|
||||
</n-flex>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.problem-list-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, auto) minmax(250px, 1fr);
|
||||
align-items: start;
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.problem-list-toolbar :deep(.n-space) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.problem-list-hitokoto {
|
||||
justify-self: end;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.problem-list-toolbar {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.problem-list-toolbar :deep(.n-space) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
53
apps/web/src/oj/problem/utils/pythonSyntaxCheck.ts
Normal file
53
apps/web/src/oj/problem/utils/pythonSyntaxCheck.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export interface PythonSyntaxError {
|
||||
line: number
|
||||
}
|
||||
|
||||
let skulptPromise: Promise<any> | null = null
|
||||
|
||||
/**
|
||||
* 按需加载 Skulpt(约 233KB gzip),只在提交 Python3 代码时才下载。
|
||||
* 结果缓存,同一页面只加载一次。
|
||||
*/
|
||||
function loadSkulpt(): Promise<any> {
|
||||
if (!skulptPromise) {
|
||||
// @ts-ignore - skulpt has no type definitions
|
||||
skulptPromise = import("skulpt").then((m) => m.default ?? m)
|
||||
}
|
||||
return skulptPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* 提前把 Skulpt 拉下来,避免点提交时才开始下载。
|
||||
* 失败不影响功能,提交时会再试一次。
|
||||
*/
|
||||
export function prefetchPythonSyntaxChecker() {
|
||||
loadSkulpt().catch(() => {
|
||||
skulptPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 Skulpt 检测 Python 代码中的语法错误。
|
||||
* 只编译不执行,不受 input() 等 IO 调用影响。
|
||||
* 加载失败时返回 null(放行提交),交给后端判题兜底。
|
||||
*/
|
||||
export async function checkPythonSyntax(
|
||||
code: string,
|
||||
): Promise<PythonSyntaxError | null> {
|
||||
let Sk: any
|
||||
try {
|
||||
Sk = await loadSkulpt()
|
||||
} catch {
|
||||
skulptPromise = null
|
||||
return null
|
||||
}
|
||||
|
||||
Sk.configure({ output: () => {} })
|
||||
try {
|
||||
Sk.compile(code, "prog.py", "exec")
|
||||
return null
|
||||
} catch (e: any) {
|
||||
const line: number = e?.traceback?.[0]?.lineno ?? 1
|
||||
return { line }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user