feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码
This commit is contained in:
53
apps/web/src/oj/submission/components/ButtonWithSearch.vue
Normal file
53
apps/web/src/oj/submission/components/ButtonWithSearch.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { USERNAME_CLASS_RE } from "utils/constants"
|
||||
|
||||
interface Props {
|
||||
type: "题目" | "用户"
|
||||
username?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emits = defineEmits(["click", "search", "filterClass"])
|
||||
|
||||
// 用同一个正则判断,避免 kstest 这类 ks 开头但没有班级号的用户名
|
||||
// 也显示出按钮,点了却什么都不发生
|
||||
const showFilterClass = computed(() => {
|
||||
return props.type === "用户" && USERNAME_CLASS_RE.test(props.username ?? "")
|
||||
})
|
||||
|
||||
function filterClass() {
|
||||
const match = props.username!.match(USERNAME_CLASS_RE)
|
||||
const classname = match ? match[0] : ""
|
||||
if (!classname) return
|
||||
emits("filterClass", classname)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<n-button text type="info" @click="$emit('click')">
|
||||
<slot></slot>
|
||||
</n-button>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button text @click="$emit('search')">
|
||||
<template #icon>
|
||||
<Icon icon="streamline-emojis:magnifying-glass-tilted-left"></Icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
{{ "搜索" + props.type }}
|
||||
</n-tooltip>
|
||||
<n-tooltip v-if="showFilterClass">
|
||||
<template #trigger>
|
||||
<n-button text @click="filterClass">
|
||||
<template #icon>
|
||||
<Icon icon="ph:funnel"></Icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
筛选班级
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
</template>
|
||||
35
apps/web/src/oj/submission/components/FlowchartLink.vue
Normal file
35
apps/web/src/oj/submission/components/FlowchartLink.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<n-button v-if="showLink" type="info" text @click="handleClick">
|
||||
{{ flowchart.id.slice(0, 12) }}
|
||||
</n-button>
|
||||
<n-text v-else class="flowchart-id" @click="handleClick">
|
||||
{{ flowchart.id.slice(0, 12) }}
|
||||
</n-text>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { FlowchartSubmissionListItem } from "utils/types"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
interface Props {
|
||||
flowchart: FlowchartSubmissionListItem
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
showDetail: [id: string]
|
||||
}>()
|
||||
|
||||
const showLink = computed(() => {
|
||||
if (!userStore.isAuthed) return false
|
||||
if (userStore.isSuperAdmin) return true
|
||||
return props.flowchart.username === userStore.user?.username
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
emit("showDetail", props.flowchart.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
226
apps/web/src/oj/submission/components/FlowchartScoreDetail.vue
Normal file
226
apps/web/src/oj/submission/components/FlowchartScoreDetail.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<n-grid v-if="submission" :cols="5" :x-gap="16">
|
||||
<!-- 左侧:流程图预览区域 -->
|
||||
<n-gi :span="3">
|
||||
<n-card title="流程图预览">
|
||||
<template #header-extra>
|
||||
<n-button
|
||||
v-if="!renderError && submission?.mermaid_code"
|
||||
quaternary
|
||||
size="small"
|
||||
@click="showLargeImage = true"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="ph:corners-out" />
|
||||
</template>
|
||||
查看大图
|
||||
</n-button>
|
||||
</template>
|
||||
<div class="flowchart">
|
||||
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
||||
{{ renderError }}
|
||||
</n-alert>
|
||||
<Teleport v-else to="body" :disabled="!showLargeImage">
|
||||
<div
|
||||
:class="['flowchart', { 'flowchart-fullscreen': showLargeImage }]"
|
||||
ref="mermaidContainer"
|
||||
></div>
|
||||
<div v-if="showLargeImage" class="fullscreen-toolbar">
|
||||
<n-button secondary round @click="showLargeImage = false">
|
||||
<template #icon>
|
||||
<Icon icon="ph:corners-in" />
|
||||
</template>
|
||||
退出大图
|
||||
</n-button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<!-- 右侧:评分详情区域 -->
|
||||
<n-gi :span="2">
|
||||
<!-- AI反馈 -->
|
||||
<n-card
|
||||
v-if="submission.ai_feedback"
|
||||
size="small"
|
||||
title="AI反馈"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<n-text>{{ submission.ai_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="
|
||||
submission.ai_criteria_details &&
|
||||
Object.keys(submission.ai_criteria_details).length > 0
|
||||
"
|
||||
size="small"
|
||||
title="详细评分"
|
||||
>
|
||||
<div
|
||||
v-for="(detail, key) in submission.ai_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-spin v-else :show="loading" class="loading-container"> </n-spin>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import type { FlowchartSubmission } from "utils/types"
|
||||
import { useMermaid } from "shared/composables/useMermaid"
|
||||
|
||||
interface Props {
|
||||
submissionId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
||||
const { renderError, renderFlowchart } = useMermaid()
|
||||
|
||||
const submission = ref<FlowchartSubmission | null>(null)
|
||||
const loading = ref(false)
|
||||
const rendering = ref(false)
|
||||
const showLargeImage = ref(false)
|
||||
const suggestionLines = computed(() =>
|
||||
splitSuggestionLines(submission.value?.ai_suggestions),
|
||||
)
|
||||
|
||||
function splitSuggestionLines(suggestions?: string | null) {
|
||||
return suggestions
|
||||
? suggestions
|
||||
.split("\n")
|
||||
.map((suggestion) => suggestion.trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
function 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"
|
||||
}
|
||||
|
||||
async function loadSubmission() {
|
||||
if (!props.submissionId) return
|
||||
showLargeImage.value = false
|
||||
loading.value = true
|
||||
try {
|
||||
const { getFlowchartSubmission } = await import("oj/api")
|
||||
const res = await getFlowchartSubmission(props.submissionId)
|
||||
submission.value = res.data
|
||||
|
||||
// 渲染流程图
|
||||
if (submission.value?.mermaid_code) {
|
||||
rendering.value = true
|
||||
await nextTick()
|
||||
await renderFlowchart(
|
||||
mermaidContainer.value,
|
||||
submission.value.mermaid_code,
|
||||
)
|
||||
rendering.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load submission:", error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.submissionId, loadSubmission, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flowchart {
|
||||
height: 500px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 全屏大图:覆盖整个视口,脱离弹框宽度限制 */
|
||||
.flowchart-fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4000;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
padding: 32px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
/* 改为可滚动块布局,超出视口的大图可以滚动查看 */
|
||||
display: block;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.fullscreen-toolbar {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 4001;
|
||||
}
|
||||
|
||||
/* 确保 SVG 图表占满容器 */
|
||||
:deep(.flowchart > svg) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 全屏时按自然尺寸显示并水平居中,配合容器滚动 */
|
||||
:deep(.flowchart-fullscreen > svg) {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
min-height: 600px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
25
apps/web/src/oj/submission/components/Grade.vue
Normal file
25
apps/web/src/oj/submission/components/Grade.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<n-text :type="gradeType(grade)">
|
||||
<span>{{ score }}</span>
|
||||
<span>({{ grade }})</span>
|
||||
</n-text>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Grade } from "utils/types"
|
||||
defineProps<{
|
||||
score: number
|
||||
grade: Grade
|
||||
}>()
|
||||
|
||||
function gradeType(grade: Grade) {
|
||||
return (
|
||||
{
|
||||
S: "success",
|
||||
A: "info",
|
||||
B: "warning",
|
||||
C: "error",
|
||||
} as const
|
||||
)[grade]
|
||||
}
|
||||
</script>
|
||||
<style scoped></style>
|
||||
52
apps/web/src/oj/submission/components/SubmissionLink.vue
Normal file
52
apps/web/src/oj/submission/components/SubmissionLink.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<n-flex v-if="props.submission.show_link" align="center">
|
||||
<n-button text type="info" @click="$emit('showCode')">
|
||||
{{ props.submission.id.slice(0, 12) }}
|
||||
</n-button>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button text @click="goto">
|
||||
<template #icon>
|
||||
<Icon icon="catppuccin:folder-debug"></Icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
查看测试详情
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
<n-flex v-else-if="isOwnSubmission" align="center">
|
||||
<span>{{ props.submission.id.slice(0, 12) }}</span>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button text>
|
||||
<template #icon>
|
||||
<Icon icon="catppuccin:lock"></Icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
这道题在你已经加入的题单中,只有在题单中完成此题,代码才可见。
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
<span v-else>{{ props.submission.id.slice(0, 12) }}</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import type { SubmissionListItem } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
submission: SubmissionListItem
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
defineEmits(["showCode"])
|
||||
|
||||
const userStore = useUserStore()
|
||||
const isOwnSubmission = computed(
|
||||
() => userStore.profile?.user?.id === props.submission.user_id,
|
||||
)
|
||||
|
||||
function goto() {
|
||||
window.open("/submission/" + props.submission.id, "_blank")
|
||||
}
|
||||
</script>
|
||||
168
apps/web/src/oj/submission/detail.vue
Normal file
168
apps/web/src/oj/submission/detail.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { getSubmission } from "oj/api"
|
||||
import {
|
||||
JUDGE_STATUS,
|
||||
LANGUAGE_FORMAT_VALUE,
|
||||
LANGUAGE_SHOW_VALUE,
|
||||
} from "utils/constants"
|
||||
import {
|
||||
parseTime,
|
||||
submissionMemoryFormat,
|
||||
submissionTimeFormat,
|
||||
utoa,
|
||||
} from "utils/functions"
|
||||
import type { Submission } from "utils/types"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
import storage from "utils/storage"
|
||||
|
||||
const props = defineProps<{
|
||||
submissionID: string
|
||||
problemID?: string
|
||||
submission?: Submission
|
||||
hideList?: boolean
|
||||
}>()
|
||||
|
||||
// 在弹框中使用时,父组件监听此事件关闭弹框,否则弹框会挡住已更新的编辑器
|
||||
const emit = defineEmits<{ copied: [] }>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const codeStore = useCodeStore()
|
||||
|
||||
const { isMobile, isDesktop } = useBreakpoints()
|
||||
|
||||
const submission = ref<Submission>()
|
||||
const loading = ref(false)
|
||||
|
||||
async function init() {
|
||||
submission.value = props.submission
|
||||
if (submission.value) return
|
||||
loading.value = true
|
||||
const res = await getSubmission(props.submissionID)
|
||||
submission.value = res.data
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
]
|
||||
|
||||
function copyToCat() {
|
||||
const lang = LANGUAGE_FORMAT_VALUE[submission.value!.language]
|
||||
const data = {
|
||||
lang,
|
||||
code: submission.value!.code,
|
||||
input: "",
|
||||
}
|
||||
const base64 = utoa(JSON.stringify(data))
|
||||
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
|
||||
window.open(url, "_blank")
|
||||
}
|
||||
|
||||
function copyToProblem() {
|
||||
const { code, language, contest } = submission.value!
|
||||
// 编辑器的 storageKey 用 display id(problem._id),等于 props.problemID,
|
||||
// 而非 submission.problem(内部数字 id)
|
||||
const contestIDForKey = contest || null
|
||||
const storageKey = `problem_${props.problemID}_contest_${contestIDForKey}_lang_${language}`
|
||||
storage.set(storageKey, code)
|
||||
// 设置语言 + 代码:localStorage 覆盖全新挂载的编辑器,
|
||||
// setCode 覆盖已挂载(同页 modal)的编辑器
|
||||
codeStore.setLanguage(language)
|
||||
codeStore.setCode(code)
|
||||
|
||||
const problemSetId = (route.params.problemSetId as string) ?? ""
|
||||
if (contest) {
|
||||
router.push({
|
||||
name: "contest problem",
|
||||
params: { contestID: String(contest), problemID: props.problemID },
|
||||
})
|
||||
} else if (problemSetId) {
|
||||
router.push({
|
||||
name: "problemset problem",
|
||||
params: { problemSetId, problemID: props.problemID },
|
||||
})
|
||||
} else {
|
||||
router.push({
|
||||
name: "problem",
|
||||
params: { problemID: props.problemID },
|
||||
})
|
||||
}
|
||||
|
||||
emit("copied")
|
||||
}
|
||||
|
||||
onMounted(init)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical v-if="submission" :size="24">
|
||||
<n-flex :vertical="isMobile" justify="space-between">
|
||||
<n-alert
|
||||
style="flex: 1"
|
||||
:type="JUDGE_STATUS[submission.result]['type']"
|
||||
:title="JUDGE_STATUS[submission.result]['title']"
|
||||
>
|
||||
<n-flex>
|
||||
<span>提交时间:{{ parseTime(submission.create_time) }}</span>
|
||||
<span>编程语言:{{ LANGUAGE_SHOW_VALUE[submission.language] }}</span>
|
||||
<span>用户:{{ submission.username }}</span>
|
||||
</n-flex>
|
||||
</n-alert>
|
||||
<n-flex :vertical="isDesktop" justify="center">
|
||||
<n-button
|
||||
v-if="submission.language !== 'SQL'"
|
||||
secondary
|
||||
@click="copyToCat"
|
||||
>
|
||||
复制到自测猫
|
||||
</n-button>
|
||||
<n-button secondary @click="copyToProblem">复制回到题目</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-card embedded>
|
||||
<n-code
|
||||
class="code"
|
||||
:language="LANGUAGE_FORMAT_VALUE[submission.language]"
|
||||
:code="submission.code"
|
||||
show-line-numbers
|
||||
/>
|
||||
</n-card>
|
||||
<n-data-table
|
||||
v-if="!hideList && submission.info && submission.info.data"
|
||||
:columns="columns"
|
||||
:data="submission.info.data"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-spin v-else :show="loading" class="loading-container"> </n-spin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.code {
|
||||
font-size: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
.loading-container {
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
573
apps/web/src/oj/submission/list.vue
Normal file
573
apps/web/src/oj/submission/list.vue
Normal file
@@ -0,0 +1,573 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from "naive-ui"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import {
|
||||
adminRejudge,
|
||||
getFlowchartSubmissions,
|
||||
getSubmissions,
|
||||
getTodaySubmissionCount,
|
||||
retryFlowchartSubmission,
|
||||
} from "oj/api"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type {
|
||||
FlowchartSubmissionListItem,
|
||||
LANGUAGE,
|
||||
SubmissionListItem,
|
||||
} from "utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
import ButtonWithSearch from "./components/ButtonWithSearch.vue"
|
||||
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
|
||||
import FlowchartStatisticsPanel from "shared/components/FlowchartStatisticsPanel.vue"
|
||||
import SubmissionLink from "./components/SubmissionLink.vue"
|
||||
import SubmissionDetail from "./detail.vue"
|
||||
import Grade from "./components/Grade.vue"
|
||||
import FlowchartLink from "./components/FlowchartLink.vue"
|
||||
import FlowchartScoreDetail from "./components/FlowchartScoreDetail.vue"
|
||||
|
||||
interface SubmissionQuery {
|
||||
username: string
|
||||
result: string
|
||||
myself: "0" | "1"
|
||||
problem: string
|
||||
language: LANGUAGE | ""
|
||||
today: "0" | "1"
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const message = useMessage()
|
||||
|
||||
const { isMobile, isDesktop } = useBreakpoints()
|
||||
|
||||
const submissions = ref<SubmissionListItem[]>([])
|
||||
const flowcharts = ref<FlowchartSubmissionListItem[]>([])
|
||||
const total = ref(0)
|
||||
const todayCount = ref(0)
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<SubmissionQuery>({
|
||||
username: useRouteQuery("username", "").value,
|
||||
result: useRouteQuery("result", "").value,
|
||||
myself: useRouteQuery("myself", "0").value,
|
||||
problem: useRouteQuery("problem", "").value,
|
||||
language: useRouteQuery("language", "").value,
|
||||
today: "0",
|
||||
})
|
||||
const submissionID = ref("")
|
||||
const problemDisplayID = ref("")
|
||||
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
|
||||
|
||||
const [codePanel, toggleCodePanel] = useToggle(false)
|
||||
const [scoreDetailPanel, toggleScoreDetailPanel] = useToggle(false)
|
||||
const selectedFlowchartId = ref("")
|
||||
const selectedFlowchart = computed(() => {
|
||||
return flowcharts.value.find((f) => f.id === selectedFlowchartId.value)
|
||||
})
|
||||
|
||||
const resultOptions: SelectOption[] = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "答案正确", value: "0" },
|
||||
{ label: "语法未通过", value: "10" },
|
||||
{ label: "答案错误", value: "-1" },
|
||||
{ label: "编译失败", value: "-2" },
|
||||
{ label: "运行时错误", value: "4" },
|
||||
]
|
||||
|
||||
const gradeOptions: SelectOption[] = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "S级", value: "S" },
|
||||
{ label: "A级", value: "A" },
|
||||
{ label: "B级", value: "B" },
|
||||
{ label: "C级", value: "C" },
|
||||
]
|
||||
|
||||
const languageOptions: SelectOption[] = [
|
||||
{ label: "流程图", value: "Flowchart" },
|
||||
{ label: "全部语言", value: "" },
|
||||
{ label: "Python", value: "Python3" },
|
||||
{ label: "C语言", value: "C" },
|
||||
{ label: "C++", value: "C++" },
|
||||
]
|
||||
async function listSubmissions() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = query.limit * (query.page - 1)
|
||||
if (query.language === "Flowchart") {
|
||||
const res = await getFlowchartSubmissions({
|
||||
username: query.username,
|
||||
problem_id: query.problem,
|
||||
myself: query.myself,
|
||||
offset,
|
||||
limit: query.limit,
|
||||
today: query.today,
|
||||
grade: query.result,
|
||||
})
|
||||
total.value = res.data.total
|
||||
flowcharts.value = res.data.results
|
||||
} else {
|
||||
const res = await getSubmissions({
|
||||
...query,
|
||||
offset,
|
||||
problem_id: query.problem,
|
||||
contest_id: (route.params.contestID as string) ?? "",
|
||||
language: query.language,
|
||||
today: query.today,
|
||||
})
|
||||
submissions.value = res.data.results
|
||||
total.value = res.data.total
|
||||
}
|
||||
}
|
||||
|
||||
async function getTodayCount() {
|
||||
const res = await getTodaySubmissionCount(query.language)
|
||||
todayCount.value = res.data
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
listSubmissions()
|
||||
if (route.name === "submissions") {
|
||||
getTodayCount()
|
||||
}
|
||||
})
|
||||
|
||||
function search(username: string, problem: string) {
|
||||
query.username = username
|
||||
query.problem = problem
|
||||
}
|
||||
|
||||
function clear() {
|
||||
clearQuery()
|
||||
}
|
||||
|
||||
async function rejudge(submissionID: string) {
|
||||
await adminRejudge(submissionID)
|
||||
message.success("重新判分成功")
|
||||
listSubmissions()
|
||||
}
|
||||
|
||||
async function retryFlowchart(submissionId: string) {
|
||||
await retryFlowchartSubmission(submissionId)
|
||||
message.success("重新评分已提交")
|
||||
listSubmissions()
|
||||
}
|
||||
|
||||
function problemClicked(row: SubmissionListItem | FlowchartSubmissionListItem) {
|
||||
if (route.name === "contest submissions") {
|
||||
const path = router.resolve({
|
||||
name: "contest problem",
|
||||
params: {
|
||||
problemID: row.problem,
|
||||
},
|
||||
})
|
||||
window.open(path.href, "_blank")
|
||||
} else {
|
||||
window.open("/problem/" + row.problem, "_blank")
|
||||
}
|
||||
}
|
||||
|
||||
function showCodePanel(id: string, problem: string) {
|
||||
toggleCodePanel(true)
|
||||
submissionID.value = id
|
||||
problemDisplayID.value = problem
|
||||
}
|
||||
|
||||
function showScoreDetail(id: string) {
|
||||
selectedFlowchartId.value = id
|
||||
toggleScoreDetailPanel(true)
|
||||
}
|
||||
|
||||
function getGradeType(grade?: string) {
|
||||
if (!grade) return "default"
|
||||
if (grade === "S") return "primary"
|
||||
if (grade === "A") return "info"
|
||||
if (grade === "B") return "warning"
|
||||
return "error"
|
||||
}
|
||||
|
||||
// 监听用户名和题号变化(防抖)
|
||||
watchDebounced(() => [query.username, query.problem], listSubmissions, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
() => [
|
||||
query.page,
|
||||
query.limit,
|
||||
query.myself,
|
||||
query.result,
|
||||
query.language,
|
||||
query.today,
|
||||
],
|
||||
listSubmissions,
|
||||
)
|
||||
|
||||
// 切换语言时重置过滤条件,刷新今日提交数
|
||||
watch(
|
||||
() => query.language,
|
||||
() => {
|
||||
query.result = ""
|
||||
if (route.name === "submissions") getTodayCount()
|
||||
},
|
||||
)
|
||||
|
||||
// 登录状态变化后刷新提交列表,更新提交编号列的可点击状态
|
||||
watch(
|
||||
() => userStore.isAuthed,
|
||||
() => {
|
||||
listSubmissions()
|
||||
if (route.name === "submissions") getTodayCount()
|
||||
},
|
||||
)
|
||||
|
||||
const columns = computed(() => {
|
||||
const res: DataTableColumn<SubmissionListItem>[] = [
|
||||
{
|
||||
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
|
||||
key: "create_time",
|
||||
minWidth: 200,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
|
||||
key: "id",
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
h(SubmissionLink, {
|
||||
submission: row,
|
||||
onShowCode: () => showCodePanel(row.id, row.problem),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("状态", "streamline-emojis:panda-face"),
|
||||
key: "status",
|
||||
minWidth: 140,
|
||||
render: (row) => h(SubmissionResultTag, { result: row.result }),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("题目", "streamline-emojis:blossom"),
|
||||
key: "problem",
|
||||
minWidth: 300,
|
||||
render: (row) =>
|
||||
h(
|
||||
ButtonWithSearch,
|
||||
{
|
||||
type: "题目",
|
||||
onClick: () => problemClicked(row),
|
||||
onSearch: () => (query.problem = row.problem),
|
||||
},
|
||||
() => `${row.problem} ${row.problem_title}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("语言", "streamline-ultimate-color:earth-pin-2"),
|
||||
key: "language",
|
||||
minWidth: 120,
|
||||
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"用户",
|
||||
"streamline-emojis:smiling-face-with-sunglasses",
|
||||
),
|
||||
key: "username",
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
h(
|
||||
ButtonWithSearch,
|
||||
{
|
||||
type: "用户",
|
||||
username: row.username,
|
||||
onClick: () => window.open("/user?name=" + row.username, "_blank"),
|
||||
onSearch: () => (query.username = row.username),
|
||||
onFilterClass: (classname: string) => (query.username = classname),
|
||||
},
|
||||
() => row.username,
|
||||
),
|
||||
},
|
||||
]
|
||||
if (!route.params.contestID && userStore.isTeacherOrAbove) {
|
||||
res.push({
|
||||
title: renderTableTitle("选项", "streamline-emojis:wrench"),
|
||||
key: "rejudge",
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
quaternary: true,
|
||||
size: "small",
|
||||
type: "primary",
|
||||
onClick: () => rejudge(row.id),
|
||||
},
|
||||
() => "重新判题",
|
||||
),
|
||||
})
|
||||
}
|
||||
return res
|
||||
})
|
||||
|
||||
const flowchartColumns = computed(() => {
|
||||
const res: DataTableColumn<FlowchartSubmissionListItem>[] = [
|
||||
{
|
||||
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
|
||||
key: "create_time",
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
|
||||
key: "id",
|
||||
render: (row) =>
|
||||
h(FlowchartLink, {
|
||||
flowchart: row,
|
||||
onShowDetail: (id: string) => showScoreDetail(id),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("题目", "streamline-emojis:blossom"),
|
||||
key: "problem_title",
|
||||
render: (row) =>
|
||||
h(
|
||||
ButtonWithSearch,
|
||||
{
|
||||
type: "题目",
|
||||
onClick: () => problemClicked(row),
|
||||
onSearch: () => (query.problem = row.problem),
|
||||
},
|
||||
() => `${row.problem} ${row.problem_title}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"评分",
|
||||
"streamline-ultimate-color:analytics-bars-3d",
|
||||
),
|
||||
key: "ai_score",
|
||||
render: (row) => h(Grade, { score: row.ai_score, grade: row.ai_grade }),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"用户",
|
||||
"streamline-emojis:smiling-face-with-sunglasses",
|
||||
),
|
||||
key: "username",
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
h(
|
||||
ButtonWithSearch,
|
||||
{
|
||||
type: "用户",
|
||||
username: row.username,
|
||||
onClick: () => window.open("/user?name=" + row.username, "_blank"),
|
||||
onSearch: () => (query.username = row.username),
|
||||
onFilterClass: (classname: string) => (query.username = classname),
|
||||
},
|
||||
() => row.username,
|
||||
),
|
||||
},
|
||||
]
|
||||
if (!route.params.contestID && userStore.isTeacherOrAbove) {
|
||||
res.push({
|
||||
title: renderTableTitle("选项", "streamline-emojis:wrench"),
|
||||
key: "retry",
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
quaternary: true,
|
||||
size: "small",
|
||||
type: "primary",
|
||||
onClick: () => retryFlowchart(row.id),
|
||||
},
|
||||
() => "重新判题",
|
||||
),
|
||||
})
|
||||
}
|
||||
return res
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<n-flex vertical size="large">
|
||||
<n-space>
|
||||
<n-form :show-feedback="false" inline label-placement="left">
|
||||
<n-form-item v-if="isDesktop && userStore.isAuthed" label="只看自己">
|
||||
<n-switch
|
||||
v-model:value="query.myself"
|
||||
checked-value="1"
|
||||
unchecked-value="0"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="语言" v-if="route.name !== 'contest submissions'">
|
||||
<n-select
|
||||
class="select"
|
||||
v-model:value="query.language"
|
||||
:options="languageOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="query.language === 'Flowchart' ? '等级' : '状态'">
|
||||
<n-select
|
||||
class="select"
|
||||
v-model:value="query.result"
|
||||
:options="
|
||||
query.language === 'Flowchart' ? gradeOptions : resultOptions
|
||||
"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form :show-feedback="false" inline label-placement="left">
|
||||
<n-form-item>
|
||||
<n-input
|
||||
:disabled="query.myself === '1'"
|
||||
style="width: 140px"
|
||||
clearable
|
||||
v-model:value="query.username"
|
||||
placeholder="用户"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-input
|
||||
style="width: 120px"
|
||||
clearable
|
||||
v-model:value="query.problem"
|
||||
placeholder="题号"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form :show-feedback="false" inline label-placement="left">
|
||||
<n-form-item v-if="isMobile && userStore.isAuthed" label="只看自己">
|
||||
<n-switch
|
||||
v-model:value="query.myself"
|
||||
checked-value="1"
|
||||
unchecked-value="0"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button @click="search(query.username, query.problem)">
|
||||
搜索
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button @click="clear" quaternary>重置</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
v-if="userStore.isTeacherOrAbove && route.name === 'submissions'"
|
||||
>
|
||||
<n-button
|
||||
quaternary
|
||||
type="warning"
|
||||
@click="toggleStatisticPanel(true)"
|
||||
>
|
||||
数据统计
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-tag
|
||||
v-if="todayCount > 0"
|
||||
checkable
|
||||
:checked="query.today === '1'"
|
||||
type="success"
|
||||
size="large"
|
||||
@update:checked="(v: boolean) => (query.today = v ? '1' : '0')"
|
||||
>
|
||||
<n-gradient-text v-if="query.today !== '1'" type="success">
|
||||
今日提交数:{{ todayCount }}
|
||||
</n-gradient-text>
|
||||
<template v-else>今日提交数:{{ todayCount }}</template>
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<n-data-table
|
||||
v-if="query.language === 'Flowchart'"
|
||||
:bordered="false"
|
||||
:columns="flowchartColumns"
|
||||
:data="flowcharts"
|
||||
/>
|
||||
<n-data-table
|
||||
v-else
|
||||
:bordered="false"
|
||||
:columns="columns"
|
||||
:data="submissions"
|
||||
/>
|
||||
</n-flex>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
<n-modal
|
||||
v-if="userStore.isTeacherOrAbove"
|
||||
v-model:show="statisticPanel"
|
||||
preset="card"
|
||||
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
|
||||
:content-style="{ overflow: 'auto' }"
|
||||
:title="
|
||||
query.language === 'Flowchart' ? '流程图提交的统计' : '提交记录的统计'
|
||||
"
|
||||
>
|
||||
<FlowchartStatisticsPanel
|
||||
v-if="query.language === 'Flowchart'"
|
||||
:problem="query.problem"
|
||||
:username="query.username"
|
||||
/>
|
||||
<StatisticsPanel
|
||||
v-else
|
||||
:problem="query.problem"
|
||||
:username="query.username"
|
||||
/>
|
||||
</n-modal>
|
||||
<n-modal
|
||||
v-model:show="codePanel"
|
||||
preset="card"
|
||||
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
|
||||
:content-style="{ overflow: 'auto' }"
|
||||
title="代码详情"
|
||||
>
|
||||
<SubmissionDetail
|
||||
:problemID="problemDisplayID"
|
||||
:submissionID="submissionID"
|
||||
hideList
|
||||
@copied="toggleCodePanel(false)"
|
||||
/>
|
||||
</n-modal>
|
||||
<n-modal
|
||||
v-model:show="scoreDetailPanel"
|
||||
preset="card"
|
||||
:style="{ maxWidth: isDesktop && '1000px', maxHeight: '80vh' }"
|
||||
:content-style="{ overflow: 'auto' }"
|
||||
>
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<n-text>流程图评分详情</n-text>
|
||||
<n-text
|
||||
v-if="selectedFlowchart"
|
||||
:type="getGradeType(selectedFlowchart.ai_grade)"
|
||||
>
|
||||
{{ selectedFlowchart.ai_score }}分 {{ selectedFlowchart.ai_grade }}级
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</template>
|
||||
<FlowchartScoreDetail :submissionId="selectedFlowchartId" />
|
||||
</n-modal>
|
||||
</template>
|
||||
<style scoped>
|
||||
.select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-size: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.flowchart-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user