1 parent
23844ae301
commit
d25b5dbe6d
268 files changed
+3684
-8861
No files matched your search
@@ -1,9 +1,5 @@
|
||||
import api from "utils/api"
|
||||
import type {
|
||||
AchievementList,
|
||||
AchievementSummary,
|
||||
PendingAchievement,
|
||||
} from "utils/types"
|
||||
import type { AchievementList, AchievementSummary, PendingAchievement } from "utils/types"
|
||||
|
||||
export function getAchievements(name?: string) {
|
||||
return api.get<AchievementList>("achievements", {
|
||||
|
||||
@@ -12,14 +12,10 @@ const rarityTextColor = useRarityColor()
|
||||
|
||||
// 隐藏且未解锁:后端已把名称/描述/图标和条件三件套都遮成 ??? 和 null,
|
||||
// 这里只负责不要把 null 渲染出来,也不要画出会泄露门槛的进度条
|
||||
const masked = computed(
|
||||
() => props.achievement.hidden && !props.achievement.unlocked,
|
||||
)
|
||||
const masked = computed(() => props.achievement.hidden && !props.achievement.unlocked)
|
||||
|
||||
// 获得率低于 5% 的加稀有闪光边框
|
||||
const isRare = computed(
|
||||
() => props.achievement.unlockRate > 0 && props.achievement.unlockRate < 5,
|
||||
)
|
||||
const isRare = computed(() => props.achievement.unlockRate > 0 && props.achievement.unlockRate < 5)
|
||||
|
||||
// 只有"越多越好"的成就画进度条。lte 类(如最短 AC 代码 ≤ 50 字符)
|
||||
// 画成百分比毫无意义,改成直接显示当前最好成绩
|
||||
@@ -88,9 +84,7 @@ const unlockDate = computed(() => {
|
||||
<n-flex align="center" :size="8" :wrap="false">
|
||||
<template v-if="achievement.unlocked">
|
||||
<n-text depth="3" class="nowrap">{{ unlockDate }}</n-text>
|
||||
<n-text depth="3" class="nowrap">
|
||||
仅 {{ achievement.unlockRate }}% 的人获得
|
||||
</n-text>
|
||||
<n-text depth="3" class="nowrap"> 仅 {{ achievement.unlockRate }}% 的人获得 </n-text>
|
||||
</template>
|
||||
|
||||
<template v-else-if="showProgressBar">
|
||||
@@ -107,17 +101,13 @@ const unlockDate = computed(() => {
|
||||
</template>
|
||||
|
||||
<template v-else-if="showBestSoFar">
|
||||
<n-text depth="3" class="nowrap">
|
||||
目标 ≤ {{ achievement.threshold }}
|
||||
</n-text>
|
||||
<n-text depth="3" class="nowrap"> 目标 ≤ {{ achievement.threshold }} </n-text>
|
||||
<n-text v-if="achievement.progress !== null" depth="3" class="nowrap">
|
||||
当前最好 {{ achievement.progress }}
|
||||
</n-text>
|
||||
</template>
|
||||
|
||||
<n-text v-else depth="3" class="nowrap">
|
||||
仅 {{ achievement.unlockRate }}% 的人获得
|
||||
</n-text>
|
||||
<n-text v-else depth="3" class="nowrap"> 仅 {{ achievement.unlockRate }}% 的人获得 </n-text>
|
||||
</n-flex>
|
||||
</n-thing>
|
||||
</n-card>
|
||||
|
||||
@@ -3,12 +3,7 @@ import { getAchievements, getAchievementSummary } from "oj/achievement/api"
|
||||
import { getUserBadges } from "oj/api"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useRarityColor } from "shared/composables/rarity"
|
||||
import type {
|
||||
Achievement,
|
||||
AchievementRarity,
|
||||
AchievementSummary,
|
||||
UserBadge,
|
||||
} from "utils/types"
|
||||
import type { Achievement, AchievementRarity, AchievementSummary, UserBadge } from "utils/types"
|
||||
import AchievementCard from "./components/AchievementCard.vue"
|
||||
|
||||
const route = useRoute()
|
||||
@@ -29,9 +24,7 @@ const RARITY_RANK: Record<AchievementRarity, number> = {
|
||||
}
|
||||
|
||||
const rarities = computed(() =>
|
||||
[...(summary.value?.rarity ?? [])].sort(
|
||||
(a, b) => RARITY_RANK[a.rarity] - RARITY_RANK[b.rarity],
|
||||
),
|
||||
[...(summary.value?.rarity ?? [])].sort((a, b) => RARITY_RANK[a.rarity] - RARITY_RANK[b.rarity]),
|
||||
)
|
||||
const badges = ref<UserBadge[]>([])
|
||||
const { isDesktop } = useBreakpoints()
|
||||
@@ -39,10 +32,8 @@ const tab = ref("all")
|
||||
const loading = ref(true)
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (tab.value === "unlocked")
|
||||
return achievements.value.filter((a) => a.unlocked)
|
||||
if (tab.value === "locked")
|
||||
return achievements.value.filter((a) => !a.unlocked)
|
||||
if (tab.value === "unlocked") return achievements.value.filter((a) => a.unlocked)
|
||||
if (tab.value === "locked") return achievements.value.filter((a) => !a.unlocked)
|
||||
return achievements.value
|
||||
})
|
||||
|
||||
@@ -73,11 +64,7 @@ watch(name, load)
|
||||
<n-card v-if="summary">
|
||||
<n-flex align="center" :wrap="false" :size="isDesktop ? 32 : 16">
|
||||
<n-flex vertical align="center" :size="6">
|
||||
<n-progress
|
||||
type="circle"
|
||||
:percentage="summary.percent"
|
||||
:stroke-width="8"
|
||||
>
|
||||
<n-progress type="circle" :percentage="summary.percent" :stroke-width="8">
|
||||
<n-text strong :style="{ fontSize: isDesktop ? '20px' : '14px' }">
|
||||
{{ summary.percent }}%
|
||||
</n-text>
|
||||
@@ -88,13 +75,7 @@ watch(name, load)
|
||||
</n-flex>
|
||||
|
||||
<n-flex vertical :size="8" class="rarity">
|
||||
<n-flex
|
||||
v-for="r in rarities"
|
||||
:key="r.rarity"
|
||||
align="center"
|
||||
:wrap="false"
|
||||
:size="10"
|
||||
>
|
||||
<n-flex v-for="r in rarities" :key="r.rarity" align="center" :wrap="false" :size="10">
|
||||
<n-text strong :style="{ color: rarityColor[r.rarity] }">
|
||||
{{ r.label }}
|
||||
</n-text>
|
||||
@@ -108,9 +89,7 @@ watch(name, load)
|
||||
:color="rarityColor[r.rarity]"
|
||||
:show-indicator="false"
|
||||
/>
|
||||
<n-text depth="3" class="nowrap">
|
||||
{{ r.unlocked }} / {{ r.total }}
|
||||
</n-text>
|
||||
<n-text depth="3" class="nowrap"> {{ r.unlocked }} / {{ r.total }} </n-text>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
@@ -124,13 +103,7 @@ watch(name, load)
|
||||
</n-tabs>
|
||||
|
||||
<template v-if="tab !== 'badges'">
|
||||
<n-grid
|
||||
v-if="filtered.length"
|
||||
responsive="screen"
|
||||
cols="1 s:2 l:3"
|
||||
:x-gap="12"
|
||||
:y-gap="12"
|
||||
>
|
||||
<n-grid v-if="filtered.length" responsive="screen" cols="1 s:2 l:3" :x-gap="12" :y-gap="12">
|
||||
<n-gi v-for="a in filtered" :key="a.id">
|
||||
<AchievementCard :achievement="a" />
|
||||
</n-gi>
|
||||
@@ -140,19 +113,10 @@ watch(name, load)
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<n-grid
|
||||
v-if="badges.length"
|
||||
responsive="screen"
|
||||
cols="1 s:2 l:3"
|
||||
:x-gap="12"
|
||||
:y-gap="12"
|
||||
>
|
||||
<n-grid v-if="badges.length" responsive="screen" cols="1 s:2 l:3" :x-gap="12" :y-gap="12">
|
||||
<n-gi v-for="b in badges" :key="b.id">
|
||||
<n-card size="small">
|
||||
<n-thing
|
||||
:title="b.badge?.name"
|
||||
:description="b.badge?.description"
|
||||
>
|
||||
<n-thing :title="b.badge?.name" :description="b.badge?.description">
|
||||
<template #avatar v-if="b.badge?.icon">
|
||||
<n-avatar
|
||||
:size="40"
|
||||
|
||||
@@ -13,11 +13,7 @@
|
||||
@change="onUsernameChange"
|
||||
@clear="onUsernameChange"
|
||||
/>
|
||||
<n-select
|
||||
style="width: 140px"
|
||||
:options="options"
|
||||
v-model:value="urlDuration"
|
||||
/>
|
||||
<n-select style="width: 140px" :options="options" v-model:value="urlDuration" />
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
|
||||
@@ -74,9 +70,7 @@ aiStore.targetUsername = urlUsername.value
|
||||
aiStore.duration = urlDuration.value
|
||||
|
||||
const subOptions = computed<Duration>(
|
||||
() =>
|
||||
durationFromValue(aiStore.duration) ??
|
||||
durationFromValue(DURATION_OPTIONS[0].value)!,
|
||||
() => durationFromValue(aiStore.duration) ?? durationFromValue(DURATION_OPTIONS[0].value)!,
|
||||
)
|
||||
|
||||
const start = computed(() => formatISO(sub(new Date(), subOptions.value)))
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<n-card title="几次做对" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
通过前提交了几次,看有没有在死磕
|
||||
</n-text>
|
||||
<n-text depth="3" style="font-size: 12px"> 通过前提交了几次,看有没有在死磕 </n-text>
|
||||
</template>
|
||||
<div class="chart">
|
||||
<Bar :key="chartKey" :data="data" :options="options" />
|
||||
@@ -13,13 +11,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartOptions } from "chart.js"
|
||||
import { Bar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Tooltip,
|
||||
} from "chart.js"
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<n-card title="难度分布" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
看看简单题和难题各做了多少
|
||||
</n-text>
|
||||
<n-text depth="3" style="font-size: 12px"> 看看简单题和难题各做了多少 </n-text>
|
||||
</template>
|
||||
<div style="height: 300px">
|
||||
<Bar :key="chartKey" :data="data" :options="options" />
|
||||
@@ -14,13 +12,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartOptions } from "chart.js"
|
||||
import { Bar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Tooltip,
|
||||
} from "chart.js"
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<n-card :title="title" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
做题量和提交质量的变化
|
||||
</n-text>
|
||||
<n-text depth="3" style="font-size: 12px"> 做题量和提交质量的变化 </n-text>
|
||||
</template>
|
||||
<div class="chart">
|
||||
<Chart type="bar" :key="chartKey" :data="data" :options="options" />
|
||||
@@ -67,10 +65,7 @@ const show = computed(() => aiStore.durationData.length > 0)
|
||||
// 而且逐题的等级在下面的解题表格里本来就有
|
||||
const data = computed<ChartData<"bar" | "line">>(() => ({
|
||||
labels: aiStore.durationData.map((duration) =>
|
||||
[
|
||||
parseTime(duration.start, "M月D日"),
|
||||
parseTime(duration.end, "M月D日"),
|
||||
].join("~"),
|
||||
[parseTime(duration.start, "M月D日"), parseTime(duration.end, "M月D日")].join("~"),
|
||||
),
|
||||
datasets: [
|
||||
{
|
||||
@@ -155,14 +150,11 @@ const options = computed<ChartOptions<"bar" | "line">>(() => ({
|
||||
},
|
||||
footer: (items: TooltipItem<"bar" | "line">[]) => {
|
||||
const index = items[0]?.dataIndex
|
||||
const bucket =
|
||||
index === undefined ? undefined : aiStore.durationData[index]
|
||||
const bucket = index === undefined ? undefined : aiStore.durationData[index]
|
||||
if (!bucket) return ""
|
||||
const lines = [`本期等级: ${bucket.grade || "无"}`]
|
||||
if (bucket.submissionCount > 0) {
|
||||
lines.push(
|
||||
`通过 ${bucket.acceptedCount} 次 / 共提交 ${bucket.submissionCount} 次`,
|
||||
)
|
||||
lines.push(`通过 ${bucket.acceptedCount} 次 / 共提交 ${bucket.submissionCount} 次`)
|
||||
}
|
||||
return lines
|
||||
},
|
||||
|
||||
@@ -11,13 +11,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartOptions } from "chart.js"
|
||||
import { Bar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Tooltip,
|
||||
} from "chart.js"
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
import { JUDGE_STATUS } from "utils/constants"
|
||||
@@ -75,9 +69,7 @@ const data = computed(() => ({
|
||||
],
|
||||
}))
|
||||
|
||||
const total = computed(() =>
|
||||
grouped.value.reduce((sum, [, item]) => sum + item.count, 0),
|
||||
)
|
||||
const total = computed(() => grouped.value.reduce((sum, [, item]) => sum + item.count, 0))
|
||||
|
||||
const options = computed<ChartOptions<"bar">>(() => ({
|
||||
indexAxis: "y",
|
||||
|
||||
@@ -11,14 +11,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartOptions } from "chart.js"
|
||||
import { Bar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "chart.js"
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip, Legend } from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
|
||||
@@ -31,8 +24,7 @@ const { chartKey } = useChartTheme()
|
||||
// 第二个 tab 里列成表格。这里直接画出来,不用改后端和契约
|
||||
const items = computed(() =>
|
||||
[...aiStore.detailsData.flowcharts].sort(
|
||||
(a, b) =>
|
||||
b.bestScore - a.bestScore || a.problemId.localeCompare(b.problemId),
|
||||
(a, b) => b.bestScore - a.bestScore || a.problemId.localeCompare(b.problemId),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<n-card title="过去一年的提交热力图" size="small">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
每格一周,激励持续学习
|
||||
</n-text>
|
||||
<n-text depth="3" style="font-size: 12px"> 每格一周,激励持续学习 </n-text>
|
||||
</template>
|
||||
<n-spin :show="aiStore.loading.heatmap" :delay="50">
|
||||
<div
|
||||
@@ -154,8 +152,7 @@ const tooltipStyle = computed(() => ({
|
||||
top: `${tooltip.value?.y}px`,
|
||||
}))
|
||||
|
||||
const getTooltipText = (count: number) =>
|
||||
count === 0 ? "这周没有提交" : `这周提交了 ${count} 次`
|
||||
const getTooltipText = (count: number) => (count === 0 ? "这周没有提交" : `这周提交了 ${count} 次`)
|
||||
|
||||
const showTooltip = (e: MouseEvent, cell: Cell) => {
|
||||
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<n-alert
|
||||
:show-icon="false"
|
||||
type="success"
|
||||
v-if="aiStore.detailsData.solvedCount"
|
||||
>
|
||||
<n-alert :show-icon="false" type="success" v-if="aiStore.detailsData.solvedCount">
|
||||
<span>{{ durationLabel }},</span>
|
||||
<span>你一共解决 </span>
|
||||
<b class="charming"> {{ aiStore.detailsData.solvedCount }} </b>
|
||||
@@ -33,10 +29,7 @@ const durationLabel = computed(() => {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "HH:mm")} - ${parseTime(aiStore.detailsData.end, "HH:mm")} 期间`
|
||||
} else if (aiStore.duration.includes("days")) {
|
||||
return `在 ${parseTime(aiStore.detailsData.end, "MM月DD日")}`
|
||||
} else if (
|
||||
aiStore.duration.includes("weeks") ||
|
||||
aiStore.duration.includes("months")
|
||||
) {
|
||||
} else if (aiStore.duration.includes("weeks") || aiStore.duration.includes("months")) {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "MM月DD日")} - ${parseTime(aiStore.detailsData.end, "MM月DD日")} 期间`
|
||||
} else {
|
||||
return `在 ${parseTime(aiStore.detailsData.start, "YYYY年MM月DD日")} - ${parseTime(aiStore.detailsData.end, "YYYY年MM月DD日")} 期间`
|
||||
|
||||
@@ -79,12 +79,7 @@ const columns: DataTableColumn<SolvedProblem>[] = [
|
||||
text: true,
|
||||
onClick: () => {
|
||||
if (row.problem.contestId) {
|
||||
router.push(
|
||||
"/contest/" +
|
||||
row.problem.contestId +
|
||||
"/problem/" +
|
||||
row.problem.displayId,
|
||||
)
|
||||
router.push("/contest/" + row.problem.contestId + "/problem/" + row.problem.displayId)
|
||||
} else {
|
||||
router.push("/problem/" + row.problem.displayId)
|
||||
}
|
||||
@@ -102,8 +97,7 @@ const columns: DataTableColumn<SolvedProblem>[] = [
|
||||
{
|
||||
// 用后端下发的 rankScope,不要看 className 有没有值:班里只有一个人时
|
||||
// 后端会回退到全服排名,那种学生原来看到的是「班级排名」配全服数据
|
||||
title: () =>
|
||||
aiStore.detailsData.rankScope === "class" ? "班级排名" : "全服排名",
|
||||
title: () => (aiStore.detailsData.rankScope === "class" ? "班级排名" : "全服排名"),
|
||||
key: "rank",
|
||||
width: 100,
|
||||
align: "center",
|
||||
@@ -119,12 +113,7 @@ const columns: DataTableColumn<SolvedProblem>[] = [
|
||||
{
|
||||
title: () =>
|
||||
h(NTooltip, null, {
|
||||
trigger: () =>
|
||||
h(
|
||||
"span",
|
||||
{ style: "cursor:help; border-bottom: 1px dashed" },
|
||||
"等级",
|
||||
),
|
||||
trigger: () => h("span", { style: "cursor:help; border-bottom: 1px dashed" }, "等级"),
|
||||
default: () =>
|
||||
h("div", null, [
|
||||
h("div", null, "基于同时段排名的百分位:"),
|
||||
|
||||
@@ -11,13 +11,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartOptions } from "chart.js"
|
||||
import { Bar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Tooltip,
|
||||
} from "chart.js"
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<n-card title="时间活跃度分析" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
基于全部提交,发现做题高峰时段
|
||||
</n-text>
|
||||
<n-text depth="3" style="font-size: 12px"> 基于全部提交,发现做题高峰时段 </n-text>
|
||||
</template>
|
||||
<div style="height: 300px">
|
||||
<Bar :key="chartKey" :data="data" :options="options" />
|
||||
@@ -56,18 +54,14 @@ const activityMatrix = computed(() => {
|
||||
return matrix
|
||||
})
|
||||
|
||||
const show = computed(() =>
|
||||
aiStore.detailsData.activity.some((item) => item.count > 0),
|
||||
)
|
||||
const show = computed(() => aiStore.detailsData.activity.some((item) => item.count > 0))
|
||||
|
||||
// 为每个时间段准备数据集
|
||||
const data = computed(() => {
|
||||
const datasets = TIME_PERIODS.map((period, periodIndex) => {
|
||||
return {
|
||||
label: period.label,
|
||||
data: WEEKDAYS.map(
|
||||
(_, weekday) => activityMatrix.value[weekday][periodIndex],
|
||||
),
|
||||
data: WEEKDAYS.map((_, weekday) => activityMatrix.value[weekday][periodIndex]),
|
||||
backgroundColor: getTimePeriodColor(periodIndex),
|
||||
borderColor: getTimePeriodColor(periodIndex),
|
||||
borderWidth: 1,
|
||||
|
||||
@@ -71,17 +71,8 @@ onMounted(listAnnouncements)
|
||||
watch(query, listAnnouncements, { deep: true })
|
||||
</script>
|
||||
<template>
|
||||
<n-data-table
|
||||
:bordered="false"
|
||||
:data="announcements"
|
||||
:columns="columns"
|
||||
:row-props="rowProps"
|
||||
/>
|
||||
<Pagination
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
:total="total"
|
||||
/>
|
||||
<n-data-table :bordered="false" :data="announcements" :columns="columns" :row-props="rowProps" />
|
||||
<Pagination v-model:limit="query.limit" v-model:page="query.page" :total="total" />
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
|
||||
+14
-49
@@ -107,11 +107,7 @@ export async function getProblem(problemID: string, contestID: string) {
|
||||
// `problemDetailSchema.parse(v) as ProblemDetail`,`as` 把校验结果又断言回去、
|
||||
// 等于没校验,而 `parse` 抛错会让整个题目页白屏。现在形状不符时记一条控制台
|
||||
// 分歧再放行原始数据。
|
||||
const problem: ProblemDetail = contract(
|
||||
"GET /problems/:id",
|
||||
problemDetailSchema,
|
||||
response,
|
||||
)
|
||||
const problem: ProblemDetail = contract("GET /problems/:id", problemDetailSchema, response)
|
||||
return problem
|
||||
}
|
||||
|
||||
@@ -171,9 +167,7 @@ export function getTodaySubmissionStatistics() {
|
||||
}
|
||||
|
||||
export function adminRejudge(id: string) {
|
||||
return api.post<{ ok: boolean }>(
|
||||
`submissions/${encodeURIComponent(id)}/rejudge`,
|
||||
)
|
||||
return api.post<{ ok: boolean }>(`submissions/${encodeURIComponent(id)}/rejudge`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,21 +220,13 @@ export function getClassRank(grade?: number | null) {
|
||||
return api.get<ClassRankItem[]>("rankings/classes", { params: { grade } })
|
||||
}
|
||||
|
||||
export function getUserClassRank(
|
||||
scope?: "all" | "window",
|
||||
offset?: number,
|
||||
limit?: number,
|
||||
) {
|
||||
export function getUserClassRank(scope?: "all" | "window", offset?: number, limit?: number) {
|
||||
return api.get<ClassUserRank>("me/class-rank", {
|
||||
params: { scope, offset, limit },
|
||||
})
|
||||
}
|
||||
|
||||
export function getClassPK(
|
||||
classNames: string[],
|
||||
startTime?: string,
|
||||
endTime?: string,
|
||||
) {
|
||||
export function getClassPK(classNames: string[], startTime?: string, endTime?: string) {
|
||||
return api.post<ClassComparisonResponse>("classes/comparison", {
|
||||
classNames,
|
||||
...(startTime ? { startTime } : {}),
|
||||
@@ -274,16 +260,11 @@ export function checkContestPassword(contestID: string, password: string) {
|
||||
}
|
||||
|
||||
export async function getContestProblems(contestID: string) {
|
||||
const res = await api.get<ProblemListItem[]>(
|
||||
`contests/${encodeURIComponent(contestID)}/problems`,
|
||||
)
|
||||
const res = await api.get<ProblemListItem[]>(`contests/${encodeURIComponent(contestID)}/problems`)
|
||||
return res.map(toProblemRow)
|
||||
}
|
||||
|
||||
export function getContestRank(
|
||||
contestID: string,
|
||||
query: { limit: number; offset: number },
|
||||
) {
|
||||
export function getContestRank(contestID: string, query: { limit: number; offset: number }) {
|
||||
// submissionInfo 在契约里是 Record<string, unknown>(JSONB 原文),
|
||||
// 前端在这里收窄成 ContestSubmissionInfo,见 utils/types 的 ContestRank
|
||||
return api.get<{ results: ContestRank[]; total: number }>(
|
||||
@@ -305,12 +286,9 @@ export function updateProfile(data: { realName: string; mood: string }) {
|
||||
}
|
||||
|
||||
export function getAnnouncementList(offset = 0, limit = 10) {
|
||||
return api.get<{ results: AnnouncementListItem[]; total: number }>(
|
||||
"announcements",
|
||||
{
|
||||
params: { limit, offset },
|
||||
},
|
||||
)
|
||||
return api.get<{ results: AnnouncementListItem[]; total: number }>("announcements", {
|
||||
params: { limit, offset },
|
||||
})
|
||||
}
|
||||
|
||||
export function getAnnouncement(id: number) {
|
||||
@@ -360,11 +338,7 @@ export function getAISolved(
|
||||
})
|
||||
}
|
||||
|
||||
export function getAIDurationData(
|
||||
end: string,
|
||||
duration: string,
|
||||
username?: string,
|
||||
) {
|
||||
export function getAIDurationData(end: string, duration: string, username?: string) {
|
||||
return api.get<DurationData[]>("ai/duration", {
|
||||
params: { end, duration, username },
|
||||
})
|
||||
@@ -398,9 +372,7 @@ export function getSimilarProblems(problemId: string) {
|
||||
export type { YearlyAc as YearlyACData } from "@oj2/contract"
|
||||
|
||||
export function getProblemYearlyAC(problemId: string) {
|
||||
return api.get<YearlyAc[]>(
|
||||
`problems/${encodeURIComponent(problemId)}/yearly-ac`,
|
||||
)
|
||||
return api.get<YearlyAc[]>(`problems/${encodeURIComponent(problemId)}/yearly-ac`)
|
||||
}
|
||||
|
||||
// ==================== 流程图相关API ====================
|
||||
@@ -440,9 +412,7 @@ export function getFlowchartStatistics(
|
||||
}
|
||||
|
||||
export function retryFlowchartSubmission(submissionId: string) {
|
||||
return api.post<{ status: string }>(
|
||||
`flowcharts/${encodeURIComponent(submissionId)}/retry`,
|
||||
)
|
||||
return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`)
|
||||
}
|
||||
|
||||
export function getCurrentProblemFlowchartSubmission(problemId: number) {
|
||||
@@ -482,9 +452,7 @@ export function joinProblemSet(problemSetId: number) {
|
||||
}
|
||||
|
||||
export function getUserBadges(username?: string) {
|
||||
return api.get<UserBadge[]>(
|
||||
`users/${encodeURIComponent(username ?? "me")}/badges`,
|
||||
)
|
||||
return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? "me")}/badges`)
|
||||
}
|
||||
|
||||
export function getProblemSetBadges(problemSetId: number) {
|
||||
@@ -500,10 +468,7 @@ export function getProblemSetUserProgress(
|
||||
completionStatus?: "" | "completed" | "in_progress" | "not_started"
|
||||
},
|
||||
) {
|
||||
return api.get<ProblemSetProgressList>(
|
||||
`problem-sets/${problemSetId}/user-progress`,
|
||||
{ params },
|
||||
)
|
||||
return api.get<ProblemSetProgressList>(`problem-sets/${problemSetId}/user-progress`, { params })
|
||||
}
|
||||
|
||||
export function getExercises(tutorialId: number): Promise<Exercise[]> {
|
||||
|
||||
+32
-150
@@ -67,9 +67,7 @@ const timeRangeOptions: SelectOption[] = [
|
||||
]
|
||||
|
||||
// 「全部时间」的 value 是空串,解不出来就是 null —— 正是不带时间条件的意思
|
||||
const subOptions = computed<Duration | null>(() =>
|
||||
durationFromValue(duration.value),
|
||||
)
|
||||
const subOptions = computed<Duration | null>(() => durationFromValue(duration.value))
|
||||
|
||||
// 根据时间段选项计算开始和结束时间
|
||||
function getTimeRange(): {
|
||||
@@ -121,8 +119,7 @@ async function compare() {
|
||||
|
||||
async function analyzeWithAI() {
|
||||
const timeRangeLabel =
|
||||
timeRangeOptions.find((o) => o.value === duration.value)?.label ??
|
||||
"全部时间"
|
||||
timeRangeOptions.find((o) => o.value === duration.value)?.label ?? "全部时间"
|
||||
|
||||
showAIModal.value = true
|
||||
aiContent.value = ""
|
||||
@@ -208,14 +205,7 @@ const radarChartData = computed(() => {
|
||||
return ((value - min) / (max - min)) * 100
|
||||
}
|
||||
|
||||
const metrics = [
|
||||
"总AC数",
|
||||
"平均AC数",
|
||||
"中位数AC数",
|
||||
"优秀率",
|
||||
"及格率",
|
||||
"参与度",
|
||||
]
|
||||
const metrics = ["总AC数", "平均AC数", "中位数AC数", "优秀率", "及格率", "参与度"]
|
||||
|
||||
// 计算每个指标的最大最小值
|
||||
const maxValues = [
|
||||
@@ -238,14 +228,7 @@ const radarChartData = computed(() => {
|
||||
|
||||
const datasets = comparisons.value.map((c, index) => {
|
||||
const color = getClassColor(index)
|
||||
const rawData = [
|
||||
c.totalAc,
|
||||
c.avgAc,
|
||||
c.medianAc,
|
||||
c.excellentRate,
|
||||
c.passRate,
|
||||
c.activeRate,
|
||||
]
|
||||
const rawData = [c.totalAc, c.avgAc, c.medianAc, c.excellentRate, c.passRate, c.activeRate]
|
||||
return {
|
||||
label: c.className,
|
||||
data: [
|
||||
@@ -360,86 +343,55 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
|
||||
{
|
||||
title: "班级",
|
||||
key: "class_name",
|
||||
render: (row) =>
|
||||
`${row.className.slice(0, 2)}计算机${row.className.slice(2)}班`,
|
||||
render: (row) => `${row.className.slice(0, 2)}计算机${row.className.slice(2)}班`,
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: "人数",
|
||||
key: "user_count",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#1890ff", fontWeight: "600" } },
|
||||
row.userCount,
|
||||
),
|
||||
render: (row) => h("span", { style: { color: "#1890ff", fontWeight: "600" } }, row.userCount),
|
||||
},
|
||||
{
|
||||
title: "总AC数",
|
||||
key: "total_ac",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#ff4d4f", fontWeight: "600" } },
|
||||
row.totalAc,
|
||||
),
|
||||
render: (row) => h("span", { style: { color: "#ff4d4f", fontWeight: "600" } }, row.totalAc),
|
||||
},
|
||||
{
|
||||
title: "平均AC",
|
||||
key: "avg_ac",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#52c41a", fontWeight: "600" } },
|
||||
row.avgAc.toFixed(2),
|
||||
),
|
||||
h("span", { style: { color: "#52c41a", fontWeight: "600" } }, row.avgAc.toFixed(2)),
|
||||
},
|
||||
{
|
||||
title: "中位数AC",
|
||||
key: "median_ac",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#fa8c16", fontWeight: "600" } },
|
||||
row.medianAc.toFixed(2),
|
||||
),
|
||||
h("span", { style: { color: "#fa8c16", fontWeight: "600" } }, row.medianAc.toFixed(2)),
|
||||
},
|
||||
{
|
||||
title: "前10%均值",
|
||||
key: "top10_avg",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#cf1322", fontWeight: "600" } },
|
||||
row.top10Avg.toFixed(2),
|
||||
),
|
||||
h("span", { style: { color: "#cf1322", fontWeight: "600" } }, row.top10Avg.toFixed(2)),
|
||||
},
|
||||
{
|
||||
title: "中间80%均值",
|
||||
key: "middle80_avg",
|
||||
width: 110,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#389e0d", fontWeight: "600" } },
|
||||
row.middle80Avg.toFixed(2),
|
||||
),
|
||||
h("span", { style: { color: "#389e0d", fontWeight: "600" } }, row.middle80Avg.toFixed(2)),
|
||||
},
|
||||
{
|
||||
title: "后10%均值",
|
||||
key: "bottom10_avg",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#096dd9", fontWeight: "500" } },
|
||||
row.bottom10Avg.toFixed(2),
|
||||
),
|
||||
h("span", { style: { color: "#096dd9", fontWeight: "500" } }, row.bottom10Avg.toFixed(2)),
|
||||
},
|
||||
{
|
||||
title: "优秀率",
|
||||
@@ -457,11 +409,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
|
||||
key: "pass_rate",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#52c41a", fontWeight: "600" } },
|
||||
row.passRate.toFixed(1) + "%",
|
||||
),
|
||||
h("span", { style: { color: "#52c41a", fontWeight: "600" } }, row.passRate.toFixed(1) + "%"),
|
||||
},
|
||||
{
|
||||
title: "参与度",
|
||||
@@ -527,10 +475,7 @@ const radarChartOptions = {
|
||||
<n-h2 style="margin-bottom: 0">班级PK</n-h2>
|
||||
|
||||
<n-flex :wrap="false" align="flex-start" :size="16">
|
||||
<n-form-item
|
||||
label="选择班级(至少2个)"
|
||||
style="width: 300px; margin-bottom: 0"
|
||||
>
|
||||
<n-form-item label="选择班级(至少2个)" style="width: 300px; margin-bottom: 0">
|
||||
<n-select
|
||||
v-model:value="selectedClasses"
|
||||
:options="classOptions"
|
||||
@@ -539,10 +484,7 @@ const radarChartOptions = {
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item
|
||||
label="时间段(可选)"
|
||||
style="width: 200px; margin-bottom: 0"
|
||||
>
|
||||
<n-form-item label="时间段(可选)" style="width: 200px; margin-bottom: 0">
|
||||
<n-select
|
||||
v-model:value="duration"
|
||||
:options="timeRangeOptions"
|
||||
@@ -552,12 +494,7 @@ const radarChartOptions = {
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="compare"
|
||||
:loading="loading"
|
||||
style="margin-top: 26px"
|
||||
>
|
||||
<n-button type="primary" @click="compare" :loading="loading" style="margin-top: 26px">
|
||||
开始PK
|
||||
</n-button>
|
||||
<n-button
|
||||
@@ -644,10 +581,7 @@ const radarChartOptions = {
|
||||
class="stat-avg-ac"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon
|
||||
icon="streamline-ultimate-color:analytics-pie-2"
|
||||
width="20"
|
||||
/>
|
||||
<Icon icon="streamline-ultimate-color:analytics-pie-2" width="20" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
@@ -659,10 +593,7 @@ const radarChartOptions = {
|
||||
class="stat-median-ac"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon
|
||||
icon="streamline-ultimate-color:cursor-target-1"
|
||||
width="20"
|
||||
/>
|
||||
<Icon icon="streamline-ultimate-color:cursor-target-1" width="20" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
@@ -674,10 +605,7 @@ const radarChartOptions = {
|
||||
class="stat-total-submission"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon
|
||||
icon="streamline-ultimate-color:common-file-text"
|
||||
width="20"
|
||||
/>
|
||||
<Icon icon="streamline-ultimate-color:common-file-text" width="20" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
@@ -698,12 +626,7 @@ const radarChartOptions = {
|
||||
<n-divider style="margin: 12px 0" />
|
||||
|
||||
<!-- 详细统计 - 紧凑布局,统一格式 -->
|
||||
<n-descriptions
|
||||
bordered
|
||||
:column="2"
|
||||
size="small"
|
||||
label-placement="left"
|
||||
>
|
||||
<n-descriptions bordered :column="2" size="small" label-placement="left">
|
||||
<!-- 分位数统计 -->
|
||||
<n-descriptions-item label="第一四分位数(Q1)">
|
||||
<span style="color: #9254de; font-weight: 500">{{
|
||||
@@ -745,19 +668,12 @@ const radarChartOptions = {
|
||||
|
||||
<!-- 人数 -->
|
||||
<n-descriptions-item label="人数">
|
||||
<span style="color: #1890ff; font-weight: 600">{{
|
||||
classData.userCount
|
||||
}}</span>
|
||||
<span style="color: #1890ff; font-weight: 600">{{ classData.userCount }}</span>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<!-- 比率统计 - 使用进度条图表 -->
|
||||
<n-card
|
||||
size="small"
|
||||
title="比率统计"
|
||||
embedded
|
||||
style="margin-top: 12px"
|
||||
>
|
||||
<n-card size="small" title="比率统计" embedded style="margin-top: 12px">
|
||||
<n-space vertical :size="10">
|
||||
<n-progress
|
||||
type="line"
|
||||
@@ -776,9 +692,7 @@ const radarChartOptions = {
|
||||
:border-radius="4"
|
||||
status="success"
|
||||
>
|
||||
<template #default>
|
||||
及格率: {{ classData.passRate.toFixed(1) }}%
|
||||
</template>
|
||||
<template #default> 及格率: {{ classData.passRate.toFixed(1) }}% </template>
|
||||
</n-progress>
|
||||
<n-progress
|
||||
type="line"
|
||||
@@ -787,17 +701,13 @@ const radarChartOptions = {
|
||||
:border-radius="4"
|
||||
status="info"
|
||||
>
|
||||
<template #default>
|
||||
参与度: {{ classData.activeRate.toFixed(1) }}%
|
||||
</template>
|
||||
<template #default> 参与度: {{ classData.activeRate.toFixed(1) }}% </template>
|
||||
</n-progress>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 时间段统计(如果有) -->
|
||||
<template
|
||||
v-if="hasTimeRange && classData.recentTotalAc !== undefined"
|
||||
>
|
||||
<template v-if="hasTimeRange && classData.recentTotalAc !== undefined">
|
||||
<n-descriptions
|
||||
bordered
|
||||
:column="2"
|
||||
@@ -855,11 +765,7 @@ const radarChartOptions = {
|
||||
<n-gi>
|
||||
<n-card title="多维度综合对比" style="height: 100%">
|
||||
<div style="height: 380px">
|
||||
<Radar
|
||||
v-if="radarChartData"
|
||||
:data="radarChartData"
|
||||
:options="radarChartOptions"
|
||||
/>
|
||||
<Radar v-if="radarChartData" :data="radarChartData" :options="radarChartOptions" />
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
@@ -870,29 +776,17 @@ const radarChartOptions = {
|
||||
<n-grid :cols="3" :x-gap="16" :y-gap="16">
|
||||
<n-gi>
|
||||
<div style="height: 300px">
|
||||
<Bar
|
||||
v-if="totalAcChartData"
|
||||
:data="totalAcChartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
<Bar v-if="totalAcChartData" :data="totalAcChartData" :options="chartOptions" />
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<div style="height: 300px">
|
||||
<Bar
|
||||
v-if="avgAcChartData"
|
||||
:data="avgAcChartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
<Bar v-if="avgAcChartData" :data="avgAcChartData" :options="chartOptions" />
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<div style="height: 300px">
|
||||
<Bar
|
||||
v-if="medianAcChartData"
|
||||
:data="medianAcChartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
<Bar v-if="medianAcChartData" :data="medianAcChartData" :options="chartOptions" />
|
||||
</div>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
@@ -912,11 +806,7 @@ const radarChartOptions = {
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<div style="height: 300px">
|
||||
<Bar
|
||||
v-if="passRateChartData"
|
||||
:data="passRateChartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
<Bar v-if="passRateChartData" :data="passRateChartData" :options="chartOptions" />
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
@@ -936,11 +826,7 @@ const radarChartOptions = {
|
||||
<n-grid :cols="3" :x-gap="16" :y-gap="16">
|
||||
<n-gi>
|
||||
<div style="height: 300px">
|
||||
<Bar
|
||||
v-if="top10AvgChartData"
|
||||
:data="top10AvgChartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
<Bar v-if="top10AvgChartData" :data="top10AvgChartData" :options="chartOptions" />
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
@@ -966,11 +852,7 @@ const radarChartOptions = {
|
||||
</template>
|
||||
|
||||
<!-- 对比表格 -->
|
||||
<n-card
|
||||
v-if="comparisons.length > 0"
|
||||
title="对比表格"
|
||||
style="margin-top: 20px"
|
||||
>
|
||||
<n-card v-if="comparisons.length > 0" title="对比表格" style="margin-top: 20px">
|
||||
<n-data-table :data="comparisons" :columns="tableColumns" />
|
||||
</n-card>
|
||||
</n-flex>
|
||||
|
||||
@@ -8,11 +8,7 @@ const contestStore = useContestStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-popover
|
||||
v-if="contestStore.contest"
|
||||
placement="bottom-end"
|
||||
:show-arrow="false"
|
||||
>
|
||||
<n-popover v-if="contestStore.contest" placement="bottom-end" :show-arrow="false">
|
||||
<template #trigger>
|
||||
<n-button>
|
||||
<template #icon>
|
||||
|
||||
@@ -35,18 +35,11 @@ const options: DropdownOption[] = [
|
||||
<template>
|
||||
<div v-if="contestMenuVisible">
|
||||
<n-flex v-if="isDesktop">
|
||||
<n-button :type="getCurrentType('problems')" @click="goto('problems')">
|
||||
比赛题目
|
||||
</n-button>
|
||||
<n-button
|
||||
:type="getCurrentType('submissions')"
|
||||
@click="goto('submissions')"
|
||||
>
|
||||
<n-button :type="getCurrentType('problems')" @click="goto('problems')"> 比赛题目 </n-button>
|
||||
<n-button :type="getCurrentType('submissions')" @click="goto('submissions')">
|
||||
提交信息
|
||||
</n-button>
|
||||
<n-button :type="getCurrentType('rank')" @click="goto('rank')">
|
||||
比赛排名
|
||||
</n-button>
|
||||
<n-button :type="getCurrentType('rank')" @click="goto('rank')"> 比赛排名 </n-button>
|
||||
</n-flex>
|
||||
<n-dropdown v-else :options="options" @select="goto">
|
||||
<n-button>菜单</n-button>
|
||||
|
||||
@@ -17,15 +17,7 @@ import {
|
||||
} from "chart.js"
|
||||
import type { ContestRank } from "utils/types"
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
)
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend)
|
||||
|
||||
interface Props {
|
||||
ranks: ContestRank[]
|
||||
|
||||
@@ -17,10 +17,7 @@ const { isDesktop } = useBreakpoints()
|
||||
const password = ref("")
|
||||
|
||||
async function check() {
|
||||
const error = await contestStore.checkPassword(
|
||||
props.contestID,
|
||||
password.value,
|
||||
)
|
||||
const error = await contestStore.checkPassword(props.contestID, password.value)
|
||||
if (error === "too-many-password-attempts") {
|
||||
message.error("密码错误次数太多,请 10 分钟后再试")
|
||||
} else if (!contestStore.access) {
|
||||
@@ -44,10 +41,7 @@ onMounted(() => {
|
||||
onBeforeUnmount(contestStore.clear)
|
||||
|
||||
const passwordFormVisible = computed(
|
||||
() =>
|
||||
contestStore.isPrivate &&
|
||||
!contestStore.access &&
|
||||
!contestStore.isContestAdmin,
|
||||
() => contestStore.isPrivate && !contestStore.access && !contestStore.isContestAdmin,
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -70,17 +64,9 @@ const passwordFormVisible = computed(
|
||||
<ContestMenu />
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-form
|
||||
:inline="isDesktop"
|
||||
label-placement="left"
|
||||
v-if="passwordFormVisible"
|
||||
>
|
||||
<n-form :inline="isDesktop" label-placement="left" v-if="passwordFormVisible">
|
||||
<n-form-item label="需要输入密码才能看到题目">
|
||||
<n-input
|
||||
name="ContestPassword"
|
||||
type="password"
|
||||
v-model:value="password"
|
||||
/>
|
||||
<n-input name="ContestPassword" type="password" v-model:value="password" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button @click="check" :disabled="!password">确认</n-button>
|
||||
|
||||
@@ -134,18 +134,10 @@ function rowProps(row: Contest) {
|
||||
<n-space>
|
||||
<n-form :show-feedback="false" label-placement="left" inline>
|
||||
<n-form-item label="比赛状态">
|
||||
<n-select
|
||||
style="width: 120px"
|
||||
:options="options"
|
||||
v-model:value="query.status"
|
||||
/>
|
||||
<n-select style="width: 120px" :options="options" v-model:value="query.status" />
|
||||
</n-form-item>
|
||||
<n-form-item label="标签">
|
||||
<n-select
|
||||
style="width: 120px"
|
||||
:options="tags"
|
||||
v-model:value="query.tag"
|
||||
/>
|
||||
<n-select style="width: 120px" :options="tags" v-model:value="query.tag" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form :show-feedback="false" label-placement="left" inline>
|
||||
@@ -165,16 +157,7 @@ function rowProps(row: Contest) {
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-space>
|
||||
<n-data-table
|
||||
:bordered="false"
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:row-props="rowProps"
|
||||
/>
|
||||
<n-data-table :bordered="false" :columns="columns" :data="data" :row-props="rowProps" />
|
||||
</n-flex>
|
||||
<Pagination
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
:total="total"
|
||||
/>
|
||||
<Pagination v-model:limit="query.limit" v-model:page="query.page" :total="total" />
|
||||
</template>
|
||||
@@ -53,10 +53,7 @@ const columns = ref<DataTableColumn<ContestRank>[]>([
|
||||
render: (_, index) => index + (query.page - 1) * query.limit + 1,
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"用户",
|
||||
"streamline-emojis:smiling-face-with-sunglasses",
|
||||
),
|
||||
title: renderTableTitle("用户", "streamline-emojis:smiling-face-with-sunglasses"),
|
||||
key: "username",
|
||||
width: 120,
|
||||
fixed: "left",
|
||||
@@ -73,10 +70,7 @@ const columns = ref<DataTableColumn<ContestRank>[]>([
|
||||
),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"正确数/总提交",
|
||||
"streamline-ultimate-color:color-palette",
|
||||
),
|
||||
title: renderTableTitle("正确数/总提交", "streamline-ultimate-color:color-palette"),
|
||||
key: "submission",
|
||||
width: 140,
|
||||
align: "center",
|
||||
@@ -147,16 +141,9 @@ async function addColumns() {
|
||||
]
|
||||
}
|
||||
if (status.error_number) {
|
||||
errorNumber = h(
|
||||
"span",
|
||||
{ style: "margin: 0" },
|
||||
`(-${status.error_number})`,
|
||||
)
|
||||
errorNumber = h("span", { style: "margin: 0" }, `(-${status.error_number})`)
|
||||
}
|
||||
return h("div", { class: "oj-time-with-modal" }, [
|
||||
acTime,
|
||||
errorNumber,
|
||||
])
|
||||
return h("div", { class: "oj-time-with-modal" }, [acTime, errorNumber])
|
||||
}
|
||||
},
|
||||
cellProps: (row) => {
|
||||
@@ -244,10 +231,7 @@ async function downloadExcel() {
|
||||
level = "一等奖"
|
||||
} else if (rank1 <= exportForm.first + exportForm.second) {
|
||||
level = "二等奖"
|
||||
} else if (
|
||||
rank1 <=
|
||||
exportForm.first + exportForm.second + exportForm.third
|
||||
) {
|
||||
} else if (rank1 <= exportForm.first + exportForm.second + exportForm.third) {
|
||||
level = "三等奖"
|
||||
} else {
|
||||
level = "参与奖"
|
||||
@@ -255,8 +239,7 @@ async function downloadExcel() {
|
||||
return { 用户名: rank.user.username, 等级: level }
|
||||
})
|
||||
|
||||
const csv =
|
||||
"用户名,等级\n" + rows.map((r) => `${r.用户名},${r.等级}`).join("\n")
|
||||
const csv = "用户名,等级\n" + rows.map((r) => `${r.用户名},${r.等级}`).join("\n")
|
||||
const blob = new Blob(["" + csv], { type: "text/csv;charset=utf-8" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
@@ -294,13 +277,7 @@ onMounted(() => {
|
||||
<LineChart :ranks="chart" :problems="problems" v-if="chart.length > 0" />
|
||||
|
||||
<!-- 排名表格 -->
|
||||
<n-data-table
|
||||
striped
|
||||
:single-line="false"
|
||||
:scroll-x="1200"
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
/>
|
||||
<n-data-table striped :single-line="false" :scroll-x="1200" :columns="columns" :data="data" />
|
||||
<n-space justify="end" align="center">
|
||||
<n-form
|
||||
label-placement="left"
|
||||
@@ -347,9 +324,7 @@ onMounted(() => {
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-button @click="showExportModal = false">取消</n-button>
|
||||
<n-button type="primary" :loading="exportLoading" @click="downloadExcel">
|
||||
下载 CSV
|
||||
</n-button>
|
||||
<n-button type="primary" :loading="exportLoading" @click="downloadExcel"> 下载 CSV </n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
@@ -102,9 +102,7 @@ function reset() {
|
||||
:style="lineStyle(idx)"
|
||||
@click="toggle(idx)"
|
||||
>
|
||||
<span
|
||||
style="color: #bbb; width: 22px; text-align: right; flex-shrink: 0"
|
||||
>
|
||||
<span style="color: #bbb; width: 22px; text-align: right; flex-shrink: 0">
|
||||
{{ idx + 1 }}
|
||||
</span>
|
||||
<span v-html="lineHtml[idx]" style="white-space: pre" />
|
||||
|
||||
@@ -36,9 +36,7 @@ const segments = computed<Segment[]>(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
const blankCount = computed(
|
||||
() => segments.value.filter((s) => s.type === "blank").length,
|
||||
)
|
||||
const blankCount = computed(() => segments.value.filter((s) => s.type === "blank").length)
|
||||
const userInputs = ref<string[]>([])
|
||||
const wrongBlanks = ref<Set<number>>(new Set())
|
||||
const allCorrect = ref(false)
|
||||
@@ -134,9 +132,7 @@ function inputWidth(idx: number): string {
|
||||
/>
|
||||
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button type="warning" :disabled="allCorrect" @click="submit">
|
||||
提交
|
||||
</n-button>
|
||||
<n-button type="warning" :disabled="allCorrect" @click="submit"> 提交 </n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
@@ -25,9 +25,7 @@ onMounted(init)
|
||||
watch(() => props.exercise.id, init)
|
||||
|
||||
const allPlaced = computed(() => placement.value.every((p) => p !== -1))
|
||||
const allCorrect = computed(() =>
|
||||
placement.value.every((p, i) => p === data.value.answer[i]),
|
||||
)
|
||||
const allCorrect = computed(() => placement.value.every((p, i) => p === data.value.answer[i]))
|
||||
const locked = computed(() => submitted.value && allCorrect.value)
|
||||
|
||||
function onDragStart(i: number) {
|
||||
@@ -42,9 +40,7 @@ function dropTo(bucket: number) {
|
||||
submitted.value = false
|
||||
}
|
||||
|
||||
const poolItems = computed(() =>
|
||||
order.value.filter((i) => placement.value[i] === -1),
|
||||
)
|
||||
const poolItems = computed(() => order.value.filter((i) => placement.value[i] === -1))
|
||||
function itemsIn(bucket: number): number[] {
|
||||
return order.value.filter((i) => placement.value[i] === bucket)
|
||||
}
|
||||
@@ -57,11 +53,7 @@ function itemStatus(i: number): "correct" | "wrong" | "default" {
|
||||
function chipStyle(i: number): Record<string, string> {
|
||||
const status = itemStatus(i)
|
||||
const color =
|
||||
status === "correct"
|
||||
? "#18a058"
|
||||
: status === "wrong"
|
||||
? "#d03050"
|
||||
: "var(--n-border-color)"
|
||||
status === "correct" ? "#18a058" : status === "wrong" ? "#d03050" : "var(--n-border-color)"
|
||||
const plain = color === "var(--n-border-color)"
|
||||
return {
|
||||
padding: "6px 12px",
|
||||
@@ -114,10 +106,7 @@ function reset() {
|
||||
@dragover.prevent
|
||||
@drop="dropTo(-1)"
|
||||
>
|
||||
<span
|
||||
v-if="poolItems.length === 0"
|
||||
style="color: var(--n-text-color-3); font-size: 13px"
|
||||
>
|
||||
<span v-if="poolItems.length === 0" style="color: var(--n-text-color-3); font-size: 13px">
|
||||
(已全部归类)
|
||||
</span>
|
||||
<div
|
||||
@@ -150,14 +139,7 @@ function reset() {
|
||||
@dragover.prevent
|
||||
@drop="dropTo(b)"
|
||||
>
|
||||
<p
|
||||
style="
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
"
|
||||
>
|
||||
<p style="font-weight: 600; margin: 0 0 8px; text-align: center; font-size: 14px">
|
||||
{{ bucket }}
|
||||
</p>
|
||||
<n-space :size="8">
|
||||
@@ -182,9 +164,7 @@ function reset() {
|
||||
/>
|
||||
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button type="warning" :disabled="!allPlaced || locked" @click="submit">
|
||||
提交
|
||||
</n-button>
|
||||
<n-button type="warning" :disabled="!allPlaced || locked" @click="submit"> 提交 </n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
@@ -37,9 +37,7 @@ onMounted(init)
|
||||
watch(() => props.exercise.id, init)
|
||||
|
||||
const allPaired = computed(() => pairs.value.every((p) => p !== null))
|
||||
const allCorrect = computed(() =>
|
||||
pairs.value.every((p, i) => p === data.value.answer[i]),
|
||||
)
|
||||
const allCorrect = computed(() => pairs.value.every((p, i) => p === data.value.answer[i]))
|
||||
const locked = computed(() => submitted.value && allCorrect.value)
|
||||
|
||||
function leftOf(rightIdx: number): number {
|
||||
@@ -183,9 +181,7 @@ function dotStyle(color: string): Record<string, string> {
|
||||
/>
|
||||
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button type="primary" :disabled="!allPaired || locked" @click="submit">
|
||||
提交
|
||||
</n-button>
|
||||
<n-button type="primary" :disabled="!allPaired || locked" @click="submit"> 提交 </n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
@@ -33,8 +33,7 @@ function submit() {
|
||||
if (selected.value.size === 0 || correct.value) return
|
||||
const answer = new Set(data.value.answer)
|
||||
const sel = selected.value
|
||||
const isEqual =
|
||||
sel.size === answer.size && [...sel].every((v) => answer.has(v))
|
||||
const isEqual = sel.size === answer.size && [...sel].every((v) => answer.has(v))
|
||||
emit("attempt", { correct: isEqual, answer: describe(sel) })
|
||||
if (isEqual) {
|
||||
correct.value = true
|
||||
@@ -105,9 +104,7 @@ function optionType(idx: number): "default" | "primary" | "success" {
|
||||
@click="select(idx)"
|
||||
>
|
||||
<template #icon>
|
||||
<span style="font-weight: 700">{{
|
||||
String.fromCharCode(65 + idx)
|
||||
}}</span>
|
||||
<span style="font-weight: 700">{{ String.fromCharCode(65 + idx) }}</span>
|
||||
</template>
|
||||
{{ opt }}
|
||||
</n-button>
|
||||
@@ -116,18 +113,12 @@ function optionType(idx: number): "default" | "primary" | "success" {
|
||||
<n-alert
|
||||
v-if="correct || wrong || partial"
|
||||
:type="correct ? 'success' : partial ? 'warning' : 'error'"
|
||||
:title="
|
||||
correct ? '正确!' : partial ? '部分正确,请重试' : '选择有误,请重试'
|
||||
"
|
||||
:title="correct ? '正确!' : partial ? '部分正确,请重试' : '选择有误,请重试'"
|
||||
style="margin-top: 12px"
|
||||
/>
|
||||
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="selected.size === 0 || correct"
|
||||
@click="submit"
|
||||
>
|
||||
<n-button type="primary" :disabled="selected.size === 0 || correct" @click="submit">
|
||||
提交
|
||||
</n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
|
||||
@@ -96,13 +96,7 @@ function reset() {
|
||||
/>
|
||||
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button
|
||||
type="error"
|
||||
:disabled="submitted && allCorrect"
|
||||
@click="submit"
|
||||
>
|
||||
提交
|
||||
</n-button>
|
||||
<n-button type="error" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
@@ -17,9 +17,7 @@ const lines = ref<LineItem[]>([])
|
||||
const submitted = ref(false)
|
||||
|
||||
function init() {
|
||||
const shuffled = shuffle(
|
||||
data.value.lines.map((text, idx) => ({ originalIdx: idx, text })),
|
||||
)
|
||||
const shuffled = shuffle(data.value.lines.map((text, idx) => ({ originalIdx: idx, text })))
|
||||
// 打乱后若恰好与原顺序一致,交换前两项,避免一进入就是已解出状态
|
||||
const isCorrect = shuffled.every((item, i) => item.originalIdx === i)
|
||||
if (isCorrect && shuffled.length > 1) {
|
||||
@@ -53,9 +51,7 @@ function lineStatus(idx: number): "correct" | "wrong" | "default" {
|
||||
return lines.value[idx].originalIdx === idx ? "correct" : "wrong"
|
||||
}
|
||||
|
||||
const allCorrect = computed(() =>
|
||||
lines.value.every((item, i) => item.originalIdx === i),
|
||||
)
|
||||
const allCorrect = computed(() => lines.value.every((item, i) => item.originalIdx === i))
|
||||
|
||||
function submit() {
|
||||
submitted.value = true
|
||||
@@ -70,9 +66,7 @@ function reset() {
|
||||
init()
|
||||
}
|
||||
|
||||
const lineHtml = computed<string[]>(() =>
|
||||
highlightLines(data.value.lines, props.lang),
|
||||
)
|
||||
const lineHtml = computed<string[]>(() => highlightLines(data.value.lines, props.lang))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -130,9 +124,7 @@ const lineHtml = computed<string[]>(() =>
|
||||
/>
|
||||
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button type="info" :disabled="submitted && allCorrect" @click="submit">
|
||||
提交
|
||||
</n-button>
|
||||
<n-button type="info" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
@@ -7,9 +7,7 @@ const ExerciseMcq = defineAsyncComponent(() => import("./ExerciseMcq.vue"))
|
||||
const ExerciseSort = defineAsyncComponent(() => import("./ExerciseSort.vue"))
|
||||
const ExerciseFill = defineAsyncComponent(() => import("./ExerciseFill.vue"))
|
||||
const ExerciseMatch = defineAsyncComponent(() => import("./ExerciseMatch.vue"))
|
||||
const ExercisePredict = defineAsyncComponent(
|
||||
() => import("./ExercisePredict.vue"),
|
||||
)
|
||||
const ExercisePredict = defineAsyncComponent(() => import("./ExercisePredict.vue"))
|
||||
const ExerciseDebug = defineAsyncComponent(() => import("./ExerciseDebug.vue"))
|
||||
const ExerciseGroup = defineAsyncComponent(() => import("./ExerciseGroup.vue"))
|
||||
|
||||
@@ -48,11 +46,7 @@ function onAttempt(payload: { correct: boolean; answer?: string }) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ExerciseMcq
|
||||
v-if="exercise.type === 'mcq'"
|
||||
:exercise="exercise"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseMcq v-if="exercise.type === 'mcq'" :exercise="exercise" @attempt="onAttempt" />
|
||||
<ExerciseSort
|
||||
v-else-if="exercise.type === 'sort'"
|
||||
:exercise="exercise"
|
||||
@@ -65,11 +59,7 @@ function onAttempt(payload: { correct: boolean; answer?: string }) {
|
||||
:lang="lang"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseMatch
|
||||
v-else-if="exercise.type === 'match'"
|
||||
:exercise="exercise"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseMatch v-else-if="exercise.type === 'match'" :exercise="exercise" @attempt="onAttempt" />
|
||||
<ExercisePredict
|
||||
v-else-if="exercise.type === 'predict'"
|
||||
:exercise="exercise"
|
||||
@@ -82,9 +72,5 @@ function onAttempt(payload: { correct: boolean; answer?: string }) {
|
||||
:lang="lang"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseGroup
|
||||
v-else-if="exercise.type === 'group'"
|
||||
:exercise="exercise"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseGroup v-else-if="exercise.type === 'group'" :exercise="exercise" @attempt="onAttempt" />
|
||||
</template>
|
||||
@@ -10,18 +10,14 @@ const props = defineProps<{
|
||||
|
||||
const stats = computed(() => {
|
||||
const rows = props.titles.map((t) => props.progress[t.id])
|
||||
const read = rows.filter(
|
||||
(p) => p && p.totalSeconds >= TUTORIAL_READ_SECONDS,
|
||||
).length
|
||||
const read = rows.filter((p) => p && p.totalSeconds >= TUTORIAL_READ_SECONDS).length
|
||||
const solved = rows.reduce((n, p) => n + (p?.exerciseSolved ?? 0), 0)
|
||||
const total = rows.reduce((n, p) => n + (p?.exerciseTotal ?? 0), 0)
|
||||
return { read, solved, total }
|
||||
})
|
||||
|
||||
const percent = computed(() =>
|
||||
props.titles.length
|
||||
? Math.round((stats.value.read / props.titles.length) * 100)
|
||||
: 0,
|
||||
props.titles.length ? Math.round((stats.value.read / props.titles.length) * 100) : 0,
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -36,9 +32,7 @@ const percent = computed(() =>
|
||||
/>
|
||||
<n-text depth="3" class="numbers">
|
||||
已读 {{ stats.read }}/{{ titles.length }} 课
|
||||
<template v-if="stats.total">
|
||||
· 练一练 {{ stats.solved }}/{{ stats.total }}
|
||||
</template>
|
||||
<template v-if="stats.total"> · 练一练 {{ stats.solved }}/{{ stats.total }} </template>
|
||||
</n-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,9 +6,7 @@ import type { Segment } from "../composables/useExerciseParse"
|
||||
defineProps<{ segments: Segment[]; lang?: string }>()
|
||||
|
||||
const isDark = useDark()
|
||||
const ExerciseWidget = defineAsyncComponent(
|
||||
() => import("./ExerciseWidget.vue"),
|
||||
)
|
||||
const ExerciseWidget = defineAsyncComponent(() => import("./ExerciseWidget.vue"))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -55,9 +55,7 @@ function hint(id: number) {
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
<n-text v-if="!traced" depth="3" class="login-tip">
|
||||
登录后可以记录学习进度
|
||||
</n-text>
|
||||
<n-text v-if="!traced" depth="3" class="login-tip"> 登录后可以记录学习进度 </n-text>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -8,9 +8,7 @@ const emit = defineEmits<{ go: [lesson: number] }>()
|
||||
|
||||
<template>
|
||||
<nav class="pager" :style="{ background: theme.bodyColor }">
|
||||
<n-button secondary :disabled="step <= 1" @click="emit('go', step - 1)">
|
||||
← 上一课
|
||||
</n-button>
|
||||
<n-button secondary :disabled="step <= 1" @click="emit('go', step - 1)"> ← 上一课 </n-button>
|
||||
<n-text depth="3">{{ step }} / {{ total }}</n-text>
|
||||
<n-button
|
||||
type="primary"
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import type { Exercise } from "utils/types"
|
||||
|
||||
export type Segment =
|
||||
| { type: "md"; content: string }
|
||||
| { type: "exercise"; exercise: Exercise }
|
||||
export type Segment = { type: "md"; content: string } | { type: "exercise"; exercise: Exercise }
|
||||
|
||||
export function parseExercises(
|
||||
content: string,
|
||||
exercises: Exercise[],
|
||||
): Segment[] {
|
||||
export function parseExercises(content: string, exercises: Exercise[]): Segment[] {
|
||||
const exerciseMap = new Map(exercises.map((e) => [e.id, e]))
|
||||
const segments: Segment[] = []
|
||||
const regex = /\[\[exercise:(\d+)\]\]/g
|
||||
|
||||
@@ -2,17 +2,9 @@
|
||||
<div class="learn-container">
|
||||
<template v-if="tutorial.id">
|
||||
<!-- 桌面端:目录 | 正文(居中限宽) | 可收起的示例代码 -->
|
||||
<div
|
||||
v-if="isDesktop"
|
||||
class="learn-layout"
|
||||
:class="{ 'with-code': codeOpen }"
|
||||
>
|
||||
<div v-if="isDesktop" class="learn-layout" :class="{ 'with-code': codeOpen }">
|
||||
<aside class="rail">
|
||||
<LearnSummary
|
||||
:titles="titles"
|
||||
:progress="progress"
|
||||
:traced="traced"
|
||||
/>
|
||||
<LearnSummary :titles="titles" :progress="progress" :traced="traced" />
|
||||
<LessonList
|
||||
:titles="titles"
|
||||
:step="step"
|
||||
@@ -28,12 +20,7 @@
|
||||
<n-text depth="3">第 {{ step }} / {{ titles.length }} 课</n-text>
|
||||
<n-flex align="center" justify="space-between" :wrap="false">
|
||||
<span />
|
||||
<n-button
|
||||
v-if="tutorial.code"
|
||||
size="small"
|
||||
secondary
|
||||
@click="codeOpen = !codeOpen"
|
||||
>
|
||||
<n-button v-if="tutorial.code" size="small" secondary @click="codeOpen = !codeOpen">
|
||||
{{ codeOpen ? "收起示例代码" : "展开示例代码" }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
@@ -44,11 +31,7 @@
|
||||
</main>
|
||||
|
||||
<aside v-if="tutorial.code && codeOpen" class="code-panel">
|
||||
<CodeEditor
|
||||
:language="editorLanguage"
|
||||
v-model="tutorial.code"
|
||||
height="100%"
|
||||
/>
|
||||
<CodeEditor :language="editorLanguage" v-model="tutorial.code" height="100%" />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -76,27 +59,13 @@
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<n-empty
|
||||
v-if="isEmpty"
|
||||
description="该教程还没有公开"
|
||||
style="margin-top: 80px"
|
||||
/>
|
||||
<n-empty v-if="isEmpty" description="该教程还没有公开" style="margin-top: 80px" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
Tutorial,
|
||||
Exercise,
|
||||
LANGUAGE,
|
||||
TutorialProgress,
|
||||
} from "utils/types"
|
||||
import {
|
||||
getTutorial,
|
||||
getTutorials,
|
||||
getExercises,
|
||||
getLearnProgress,
|
||||
} from "../api"
|
||||
import type { Tutorial, Exercise, LANGUAGE, TutorialProgress } from "utils/types"
|
||||
import { getTutorial, getTutorials, getExercises, getLearnProgress } from "../api"
|
||||
import { parseExercises } from "./composables/useExerciseParse"
|
||||
import { useLearnTrace } from "./composables/useLearnTrace"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
@@ -106,9 +75,7 @@ import LessonList from "./components/LessonList.vue"
|
||||
import LearnSummary from "./components/LearnSummary.vue"
|
||||
import LessonBody from "./components/LessonBody.vue"
|
||||
import PagerBar from "./components/PagerBar.vue"
|
||||
const CodeEditor = defineAsyncComponent(
|
||||
() => import("shared/components/CodeEditor.vue"),
|
||||
)
|
||||
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -125,9 +92,7 @@ const step = computed(() => {
|
||||
return parseInt(value)
|
||||
})
|
||||
|
||||
const type = computed<"python" | "c">(() =>
|
||||
route.params.type === "c" ? "c" : "python",
|
||||
)
|
||||
const type = computed<"python" | "c">(() => (route.params.type === "c" ? "c" : "python"))
|
||||
|
||||
const tutorial = ref<Partial<Tutorial>>({
|
||||
id: 0,
|
||||
@@ -136,9 +101,7 @@ const tutorial = ref<Partial<Tutorial>>({
|
||||
code: "",
|
||||
})
|
||||
|
||||
const editorLanguage = computed<LANGUAGE>(() =>
|
||||
tutorial.value.type === "c" ? "C" : "Python",
|
||||
)
|
||||
const editorLanguage = computed<LANGUAGE>(() => (tutorial.value.type === "c" ? "C" : "Python"))
|
||||
const titles = ref<{ id: number; title: string }[]>([])
|
||||
const progress = ref<Record<number, TutorialProgress>>({})
|
||||
const exercises = ref<Exercise[]>([])
|
||||
@@ -147,9 +110,7 @@ const activeTab = ref("content")
|
||||
const codeOpen = useStorage("oj2:learn-code-open", true)
|
||||
const isEmpty = ref(false)
|
||||
|
||||
const segments = computed(() =>
|
||||
parseExercises(tutorial.value.content ?? "", exercises.value),
|
||||
)
|
||||
const segments = computed(() => parseExercises(tutorial.value.content ?? "", exercises.value))
|
||||
|
||||
// 留痕的计时器。tutorial.id 变了才算换课 —— 用 step 会在内容还没加载好时就上报
|
||||
useLearnTrace(
|
||||
@@ -159,9 +120,7 @@ useLearnTrace(
|
||||
|
||||
function goToLesson(lessonNumber: number) {
|
||||
activeTab.value = "content"
|
||||
router.push(
|
||||
`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`,
|
||||
)
|
||||
router.push(`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`)
|
||||
}
|
||||
/**
|
||||
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响上课,
|
||||
@@ -174,9 +133,7 @@ async function loadProgress() {
|
||||
}
|
||||
try {
|
||||
const rows = await getLearnProgress(type.value)
|
||||
progress.value = Object.fromEntries(
|
||||
rows.map((row) => [row.tutorialId, row]),
|
||||
)
|
||||
progress.value = Object.fromEntries(rows.map((row) => [row.tutorialId, row]))
|
||||
} catch {
|
||||
progress.value = {}
|
||||
}
|
||||
@@ -188,10 +145,7 @@ async function init() {
|
||||
isEmpty.value = titles.value.length === 0
|
||||
if (isEmpty.value) return
|
||||
const id = titles.value[step.value - 1].id
|
||||
const [res2, exs] = await Promise.allSettled([
|
||||
getTutorial(id),
|
||||
getExercises(id),
|
||||
])
|
||||
const [res2, exs] = await Promise.allSettled([getTutorial(id), getExercises(id)])
|
||||
if (res2.status === "fulfilled") tutorial.value = res2.value
|
||||
exercises.value = exs.status === "fulfilled" ? exs.value : []
|
||||
learnStep.value[type.value] = step.value
|
||||
|
||||
@@ -20,8 +20,7 @@ const { isDesktop } = useBreakpoints()
|
||||
|
||||
const contestID = route.params.contestID || null
|
||||
const storageKey = computed(
|
||||
() =>
|
||||
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
() => `problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
)
|
||||
|
||||
const editorHeight = computed(() =>
|
||||
@@ -35,10 +34,7 @@ onMounted(() => {
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
)
|
||||
beginEditTrace(
|
||||
`problem_${problem.value!._id}_contest_${contestID}`,
|
||||
codeStore.code.value.length,
|
||||
)
|
||||
beginEditTrace(`problem_${problem.value!._id}_contest_${contestID}`, codeStore.code.value.length)
|
||||
})
|
||||
|
||||
const changeCode = (v: string) => {
|
||||
@@ -50,18 +46,14 @@ const changeLanguage = (v: LANGUAGE) => {
|
||||
codeStore.setCode(
|
||||
savedCode && storageKey.value.split("_").pop() === v
|
||||
? savedCode
|
||||
: problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
: problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<EditorToolbar
|
||||
:storage-key="storageKey"
|
||||
@change-language="changeLanguage"
|
||||
/>
|
||||
<EditorToolbar :storage-key="storageKey" @change-language="changeLanguage" />
|
||||
<CodeEditor
|
||||
v-model:value="codeStore.code.value"
|
||||
:language="codeStore.code.language"
|
||||
|
||||
@@ -20,8 +20,7 @@ const { input, output } = storeToRefs(codeStore)
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
|
||||
const storageKey = computed(
|
||||
() =>
|
||||
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
() => `problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -29,8 +28,7 @@ onMounted(() => {
|
||||
codeStore.setCode(storage.get(storageKey.value))
|
||||
} else {
|
||||
codeStore.setCode(
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -40,15 +38,11 @@ function changeCode(v: string) {
|
||||
}
|
||||
|
||||
function changeLanguage(v: string) {
|
||||
if (
|
||||
storage.get(storageKey.value) &&
|
||||
storageKey.value.split("_").pop() === v
|
||||
) {
|
||||
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],
|
||||
problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -60,8 +54,7 @@ const copy = async () => {
|
||||
|
||||
const reset = () => {
|
||||
codeStore.setCode(
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
|
||||
)
|
||||
storage.remove(storageKey.value)
|
||||
message.success("代码重置成功")
|
||||
@@ -72,12 +65,10 @@ const runCode = async () => {
|
||||
output.value = res.output
|
||||
}
|
||||
|
||||
const languageOptions: DropdownOption[] = problem.value!.languages.map(
|
||||
(it) => ({
|
||||
label: () => LANGUAGE_SHOW_VALUE[it],
|
||||
value: it,
|
||||
}),
|
||||
)
|
||||
const languageOptions: DropdownOption[] = problem.value!.languages.map((it) => ({
|
||||
label: () => LANGUAGE_SHOW_VALUE[it],
|
||||
value: it,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -94,9 +85,7 @@ const languageOptions: DropdownOption[] = problem.value!.languages.map(
|
||||
/>
|
||||
<n-button @click="copy">复制代码</n-button>
|
||||
<n-button @click="reset">重置代码</n-button>
|
||||
<n-button type="primary" secondary @click="runCode">
|
||||
运行代码
|
||||
</n-button>
|
||||
<n-button type="primary" secondary @click="runCode"> 运行代码 </n-button>
|
||||
</n-flex>
|
||||
<CodeEditor
|
||||
v-model:value="codeStore.code.value"
|
||||
@@ -106,12 +95,7 @@ const languageOptions: DropdownOption[] = problem.value!.languages.map(
|
||||
</n-flex>
|
||||
</template>
|
||||
<template #2>
|
||||
<n-split
|
||||
direction="vertical"
|
||||
:default-size="1 / 3"
|
||||
:min="1 / 5"
|
||||
:max="3 / 5"
|
||||
>
|
||||
<n-split direction="vertical" :default-size="1 / 3" :min="1 / 5" :max="3 / 5">
|
||||
<template #1>
|
||||
<div class="title">输入框</div>
|
||||
<n-input
|
||||
|
||||
@@ -19,14 +19,10 @@ import { Icon } from "@iconify/vue"
|
||||
import { NFlex } from "naive-ui"
|
||||
import SubmitCode from "./SubmitCode.vue"
|
||||
|
||||
const SubmitFlowchart = defineAsyncComponent(
|
||||
() => import("./SubmitFlowchart.vue"),
|
||||
)
|
||||
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
|
||||
// 只有老师看得见(下面的弹框挂了 isTeacherOrAbove),静态 import 的话每个学生
|
||||
// 打开题目都要白拉一份 chart.js(~68KB gzip)
|
||||
const StatisticsPanel = defineAsyncComponent(
|
||||
() => import("shared/components/StatisticsPanel.vue"),
|
||||
)
|
||||
const StatisticsPanel = defineAsyncComponent(() => import("shared/components/StatisticsPanel.vue"))
|
||||
|
||||
interface Props {
|
||||
storageKey: string
|
||||
@@ -74,13 +70,9 @@ const showHelpButton = computed(
|
||||
* 不像弹框那样按一下 Esc 就把协作关掉了。
|
||||
*/
|
||||
const collabHere = computed(
|
||||
() =>
|
||||
collabStore.room !== null &&
|
||||
collabStore.room.problemId === problem.value?._id,
|
||||
)
|
||||
const showCollabBar = computed(
|
||||
() => collabHere.value && userStore.isTeacherOrAbove,
|
||||
() => collabStore.room !== null && collabStore.room.problemId === problem.value?._id,
|
||||
)
|
||||
const showCollabBar = computed(() => collabHere.value && userStore.isTeacherOrAbove)
|
||||
|
||||
/**
|
||||
* 状态全塞进按钮本身。原来旁边还挂一个 n-tag 说明排队情况,一行工具栏
|
||||
@@ -212,8 +204,7 @@ const copy = async () => {
|
||||
|
||||
const reset = () => {
|
||||
codeStore.setCode(
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
|
||||
)
|
||||
storage.remove(storageKey)
|
||||
message.success("代码重置成功")
|
||||
@@ -280,11 +271,7 @@ onMounted(() => {
|
||||
|
||||
<SubmitCode v-else />
|
||||
|
||||
<n-button
|
||||
v-if="isDesktop && showGoSubmissionButton"
|
||||
:size="buttonSize"
|
||||
@click="goSubmissions"
|
||||
>
|
||||
<n-button v-if="isDesktop && showGoSubmissionButton" :size="buttonSize" @click="goSubmissions">
|
||||
提交信息
|
||||
</n-button>
|
||||
|
||||
@@ -313,11 +300,7 @@ onMounted(() => {
|
||||
</n-tag>
|
||||
<!-- 显式的「结束」:求助记录一并清掉。跳走页面发的是 leave("left"),
|
||||
那边只是退回排队 —— 见 store 里 leave 的注释 -->
|
||||
<n-button
|
||||
:size="buttonSize"
|
||||
type="primary"
|
||||
@click="collabStore.leave('done')"
|
||||
>
|
||||
<n-button :size="buttonSize" type="primary" @click="collabStore.leave('done')">
|
||||
结束协作
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
@@ -22,11 +22,7 @@ watch(
|
||||
<n-alert v-if="renderError" type="error" title="渲染失败" size="small">
|
||||
{{ renderError }}
|
||||
</n-alert>
|
||||
<div
|
||||
v-show="!renderError"
|
||||
ref="mermaidContainer"
|
||||
class="flowchart-container"
|
||||
></div>
|
||||
<div v-show="!renderError" ref="mermaidContainer" class="flowchart-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -108,9 +108,7 @@ const KIND_TAG_TYPE = {
|
||||
count: "info",
|
||||
} as const
|
||||
|
||||
const astRequirements = computed(() =>
|
||||
Object.entries(problem.value?.astRequirements ?? {}),
|
||||
)
|
||||
const astRequirements = computed(() => Object.entries(problem.value?.astRequirements ?? {}))
|
||||
|
||||
async function test(sample: Sample, index: number) {
|
||||
samples.value = samples.value.map((sample) => {
|
||||
@@ -122,10 +120,7 @@ async function test(sample: Sample, index: number) {
|
||||
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"
|
||||
const status = res.status === 3 && res.output.trim() === sample.output ? "passed" : "failed"
|
||||
return {
|
||||
...sample,
|
||||
msg: res.output,
|
||||
@@ -267,9 +262,7 @@ function type(status: ProblemStatus) {
|
||||
:total-rows="sqlExpectedQuery.total_rows"
|
||||
:truncated="sqlExpectedQuery.truncated"
|
||||
/>
|
||||
<p v-if="!problem.sqlConfig?.order_sensitive" class="sqlNote">
|
||||
结果顺序不限
|
||||
</p>
|
||||
<p v-if="!problem.sqlConfig?.order_sensitive" class="sqlNote">结果顺序不限</p>
|
||||
</template>
|
||||
<div v-for="t in sqlChangedTables" :key="t.name">
|
||||
<p class="sqlTableName">
|
||||
@@ -313,9 +306,7 @@ function type(status: ProblemStatus) {
|
||||
</p>
|
||||
<n-list bordered style="margin-bottom: 8px">
|
||||
<n-list-item v-for="(rule, i) in rules" :key="i">
|
||||
<n-tag :type="KIND_TAG_TYPE[rule.kind]">{{
|
||||
rule.description
|
||||
}}</n-tag>
|
||||
<n-tag :type="KIND_TAG_TYPE[rule.kind]">{{ rule.description }}</n-tag>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
@@ -330,19 +321,11 @@ function type(status: ProblemStatus) {
|
||||
例子 {{ index + 1 }}
|
||||
</n-flex>
|
||||
</p>
|
||||
<n-button
|
||||
size="small"
|
||||
:type="type(sample.status)"
|
||||
@click="test(sample, index)"
|
||||
>
|
||||
<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 bordered :column="2" label-style="width: 50%; min-width: 100px">
|
||||
<n-descriptions-item>
|
||||
<template #label>
|
||||
<n-flex>
|
||||
|
||||
@@ -31,9 +31,7 @@ const { problem } = storeToRefs(problemStore)
|
||||
* 协作跟着结束」:页面上没有弹框可关,结束协作只有工具栏那个按钮和离开这一页两条路。
|
||||
*/
|
||||
const collabHere = computed(
|
||||
() =>
|
||||
collabStore.room !== null &&
|
||||
collabStore.room.problemId === problem.value?._id,
|
||||
() => collabStore.room !== null && collabStore.room.problemId === problem.value?._id,
|
||||
)
|
||||
|
||||
/** 协作中的教师:编辑器里是学生的代码,不是他自己的 */
|
||||
@@ -66,8 +64,7 @@ watch(
|
||||
const wasHere = previous?.[0] ?? false
|
||||
if (here && language) {
|
||||
if (!wasHere) teacherLanguageBefore = codeStore.code.language
|
||||
if (codeStore.code.language !== language)
|
||||
codeStore.code.language = language
|
||||
if (codeStore.code.language !== language) codeStore.code.language = language
|
||||
return
|
||||
}
|
||||
if (!here && wasHere) {
|
||||
@@ -84,8 +81,7 @@ const { isDesktop } = useBreakpoints()
|
||||
|
||||
const contestID = route.params.contestID || null
|
||||
const storageKey = computed(
|
||||
() =>
|
||||
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
() => `problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
)
|
||||
|
||||
const editorHeight = computed(() =>
|
||||
@@ -100,10 +96,7 @@ function loadCode() {
|
||||
SOURCES[codeStore.code.language],
|
||||
)
|
||||
// 换了题才重新计数,同一道题重复 loadCode(协作结束读回草稿)是接着记
|
||||
beginEditTrace(
|
||||
`problem_${problem.value!._id}_contest_${contestID}`,
|
||||
codeStore.code.value.length,
|
||||
)
|
||||
beginEditTrace(`problem_${problem.value!._id}_contest_${contestID}`, codeStore.code.value.length)
|
||||
}
|
||||
|
||||
onMounted(loadCode)
|
||||
@@ -128,8 +121,7 @@ const changeLanguage = (v: LANGUAGE) => {
|
||||
codeStore.setCode(
|
||||
savedCode && storageKey.value.split("_").pop() === v
|
||||
? savedCode
|
||||
: problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
: problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,10 +131,7 @@ provide("flowchartEditorRef", flowchartEditorRef)
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<EditorToolbar
|
||||
:storage-key="storageKey"
|
||||
@change-language="changeLanguage"
|
||||
/>
|
||||
<EditorToolbar :storage-key="storageKey" @change-language="changeLanguage" />
|
||||
<!--
|
||||
协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的,
|
||||
而求助入口本身就排掉了流程图(EditorToolbar.vue 的 showHelpButton、服务端的
|
||||
@@ -150,10 +139,7 @@ provide("flowchartEditorRef", flowchartEditorRef)
|
||||
学生自己切到流程图就是不写代码了,编辑器卸载、协作正常结束(SyncCodeEditor
|
||||
的 detach),这是原来就有的语义。
|
||||
-->
|
||||
<FlowchartEditor
|
||||
v-if="codeStore.code.language === 'Flowchart'"
|
||||
ref="flowchartEditorRef"
|
||||
/>
|
||||
<FlowchartEditor v-if="codeStore.code.language === 'Flowchart'" ref="flowchartEditorRef" />
|
||||
<SyncCodeEditor
|
||||
v-else
|
||||
v-model:value="codeStore.code.value"
|
||||
|
||||
@@ -9,10 +9,7 @@ const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
||||
const { renderError, renderFlowchart } = useMermaid()
|
||||
|
||||
const renderProblemFlowchart = async () => {
|
||||
await renderFlowchart(
|
||||
mermaidContainer.value,
|
||||
problem.value?.mermaidCode ?? "",
|
||||
)
|
||||
await renderFlowchart(mermaidContainer.value, problem.value?.mermaidCode ?? "")
|
||||
}
|
||||
|
||||
onMounted(renderProblemFlowchart)
|
||||
|
||||
@@ -6,14 +6,7 @@ import { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
|
||||
import type { SUBMISSION_RESULT } from "utils/types"
|
||||
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 { 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"
|
||||
@@ -106,12 +99,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-descriptions
|
||||
bordered
|
||||
label-placement="left"
|
||||
:column="isDesktop ? 3 : 1"
|
||||
v-if="problem"
|
||||
>
|
||||
<n-descriptions bordered label-placement="left" :column="isDesktop ? 3 : 1" v-if="problem">
|
||||
<n-descriptions-item label="编号">
|
||||
{{ problem._id }}
|
||||
</n-descriptions-item>
|
||||
@@ -141,10 +129,7 @@ onMounted(() => {
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -9,20 +9,8 @@ defineProps<{
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<span>{{ problem.title }}</span>
|
||||
<Icon
|
||||
v-if="problem.allowFlowchart"
|
||||
width="18"
|
||||
icon="vscode-icons:file-type-drawio"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="problem.showFlowchart"
|
||||
width="18"
|
||||
icon="vscode-icons:file-type-graphql"
|
||||
/>
|
||||
<Icon
|
||||
v-if="problem.hasAstRules"
|
||||
width="18"
|
||||
icon="vscode-icons:file-type-light-todo"
|
||||
/>
|
||||
<Icon v-if="problem.allowFlowchart" width="18" icon="vscode-icons:file-type-drawio" />
|
||||
<Icon v-else-if="problem.showFlowchart" width="18" icon="vscode-icons:file-type-graphql" />
|
||||
<Icon v-if="problem.hasAstRules" width="18" icon="vscode-icons:file-type-light-todo" />
|
||||
</n-flex>
|
||||
</template>
|
||||
@@ -54,15 +54,12 @@ function getWheelItemStyle(index: number): CSSProperties {
|
||||
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)}%`
|
||||
},
|
||||
)
|
||||
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(", ")})`,
|
||||
@@ -192,9 +189,7 @@ function getPointerIndex(event: PointerEvent | MouseEvent) {
|
||||
|
||||
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
|
||||
)
|
||||
return ((rawIndex % wheelItems.length) + wheelItems.length) % wheelItems.length
|
||||
}
|
||||
|
||||
function preview(index: number, fromKeyboard = false) {
|
||||
@@ -225,14 +220,7 @@ function onWheelClick(event: MouseEvent) {
|
||||
}
|
||||
|
||||
async function pick(key: ReactionKey) {
|
||||
if (
|
||||
!problem.value ||
|
||||
!solved.value ||
|
||||
locked.value ||
|
||||
loading.value ||
|
||||
submitting.value
|
||||
)
|
||||
return
|
||||
if (!problem.value || !solved.value || locked.value || loading.value || submitting.value) return
|
||||
|
||||
activeIndex.value = null
|
||||
keyboardActive.value = false
|
||||
@@ -345,18 +333,10 @@ watch(
|
||||
>
|
||||
<span class="option-content">
|
||||
<span class="option-icon" aria-hidden="true">
|
||||
<Icon
|
||||
:icon="
|
||||
submitting === item.key
|
||||
? 'svg-spinners:180-ring-with-bg'
|
||||
: item.icon
|
||||
"
|
||||
/>
|
||||
<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 v-if="counts" class="option-count"> {{ counts[item.key] }} 人 </span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -420,22 +400,13 @@ watch(
|
||||
|
||||
.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);
|
||||
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)
|
||||
);
|
||||
background: color-mix(in srgb, var(--reaction-accent) 18%, var(--reaction-card));
|
||||
transform: translate(var(--push-x), var(--push-y)) scale(1.075);
|
||||
}
|
||||
|
||||
@@ -512,14 +483,11 @@ watch(
|
||||
}
|
||||
|
||||
.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);
|
||||
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(.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 {
|
||||
@@ -623,13 +591,11 @@ watch(
|
||||
}
|
||||
|
||||
.segment-face:is(.is-active, .is-submitting) {
|
||||
transform: translate(calc(var(--push-x) * 0.5), calc(var(--push-y) * 0.5))
|
||||
scale(1.03);
|
||||
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);
|
||||
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75)) scale(1.05);
|
||||
}
|
||||
|
||||
.option-content {
|
||||
@@ -637,19 +603,15 @@ watch(
|
||||
}
|
||||
|
||||
.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);
|
||||
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(.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);
|
||||
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75)) scale(1.17);
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
|
||||
@@ -48,10 +48,7 @@ const columns: DataTableColumn<SubmissionListItem>[] = [
|
||||
NTooltip,
|
||||
{},
|
||||
{
|
||||
trigger: () =>
|
||||
h(NButton, { text: true }, () =>
|
||||
h(Icon, { icon: "catppuccin:lock" }),
|
||||
),
|
||||
trigger: () => h(NButton, { text: true }, () => h(Icon, { icon: "catppuccin:lock" })),
|
||||
default: () =>
|
||||
"这道题在你已经加入的题单里,加入之前的提交先藏起来了。在题单中做出此题即可解锁;题单过了截止时间也会解锁。",
|
||||
},
|
||||
@@ -190,12 +187,7 @@ watch(query, listSubmissions)
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
<n-alert
|
||||
class="tip"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
v-if="rank === -1 && class_ac_count > 0"
|
||||
>
|
||||
<n-alert class="tip" type="error" :show-icon="false" v-if="rank === -1 && class_ac_count > 0">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<span>
|
||||
@@ -252,12 +244,7 @@ watch(query, listSubmissions)
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
<n-alert
|
||||
class="tip"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
v-if="rank === -1 && all_ac_count > 0"
|
||||
>
|
||||
<n-alert class="tip" type="error" :show-icon="false" v-if="rank === -1 && all_ac_count > 0">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<span>
|
||||
@@ -288,12 +275,7 @@ watch(query, listSubmissions)
|
||||
|
||||
<template v-if="userStore.showSubmissions && userStore.isAuthed">
|
||||
<!-- 错误分布统计 -->
|
||||
<n-flex
|
||||
v-if="statusDistribution.length"
|
||||
class="tip"
|
||||
align="center"
|
||||
:wrap="true"
|
||||
>
|
||||
<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"
|
||||
@@ -306,17 +288,8 @@ watch(query, listSubmissions)
|
||||
</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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<!-- 代码详情弹框 -->
|
||||
|
||||
@@ -18,15 +18,7 @@ import {
|
||||
} from "chart.js"
|
||||
import type { YearlyACData } from "oj/api"
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
Filler,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
)
|
||||
ChartJS.register(CategoryScale, Filler, LinearScale, LineElement, PointElement, Title, Tooltip)
|
||||
|
||||
const props = defineProps<{ data: YearlyACData[] }>()
|
||||
|
||||
|
||||
@@ -30,9 +30,7 @@ defineProps<{
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
<p v-if="truncated" class="truncNote">
|
||||
共 {{ totalRows }} 行,仅展示前 {{ rows.length }} 行
|
||||
</p>
|
||||
<p v-if="truncated" class="truncNote">共 {{ totalRows }} 行,仅展示前 {{ rows.length }} 行</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -52,9 +52,7 @@ const hintFeedbackSending = ref(false)
|
||||
const TYPE_STEP = 3
|
||||
const TYPE_INTERVAL = 24
|
||||
let typingTimer: ReturnType<typeof setInterval> | null = null
|
||||
const hintTyping = computed(
|
||||
() => hintContent.value.length < hintTarget.value.length,
|
||||
)
|
||||
const hintTyping = computed(() => hintContent.value.length < hintTarget.value.length)
|
||||
|
||||
function stopTyping() {
|
||||
if (typingTimer === null) return
|
||||
@@ -69,10 +67,7 @@ function startTyping() {
|
||||
stopTyping()
|
||||
return
|
||||
}
|
||||
hintContent.value = hintTarget.value.slice(
|
||||
0,
|
||||
hintContent.value.length + TYPE_STEP,
|
||||
)
|
||||
hintContent.value = hintTarget.value.slice(0, hintContent.value.length + TYPE_STEP)
|
||||
}, TYPE_INTERVAL)
|
||||
}
|
||||
|
||||
@@ -99,17 +94,13 @@ const msg = computed(() => {
|
||||
// 编译错误或运行时错误时给出提示;
|
||||
// SQL 题的运行错误多半是"查询题里写了增删改"这类被判题拒绝的语句,err_info 已说明原因,不套这句
|
||||
if (
|
||||
(result === SubmissionStatus.compile_error ||
|
||||
result === SubmissionStatus.runtime_error) &&
|
||||
(result === SubmissionStatus.compile_error || result === SubmissionStatus.runtime_error) &&
|
||||
props.submission.language !== "SQL"
|
||||
) {
|
||||
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
|
||||
}
|
||||
|
||||
if (
|
||||
result !== SubmissionStatus.ast_check_failed &&
|
||||
props.submission.statisticInfo?.err_info
|
||||
) {
|
||||
if (result !== SubmissionStatus.ast_check_failed && props.submission.statisticInfo?.err_info) {
|
||||
msg += props.submission.statisticInfo.err_info
|
||||
}
|
||||
|
||||
@@ -258,9 +249,7 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
||||
</n-alert>
|
||||
<n-flex
|
||||
vertical
|
||||
v-if="
|
||||
msg || infoTable.length || submission.statisticInfo?.ast_results?.length
|
||||
"
|
||||
v-if="msg || infoTable.length || submission.statisticInfo?.ast_results?.length"
|
||||
>
|
||||
<n-card v-if="submission.statisticInfo?.ast_results?.length" embedded>
|
||||
<n-flex vertical :size="8">
|
||||
@@ -270,9 +259,7 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
||||
align="center"
|
||||
:size="6"
|
||||
>
|
||||
<n-icon
|
||||
:color="rule.passed ? theme.successColor : theme.errorColor"
|
||||
>
|
||||
<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>
|
||||
@@ -287,23 +274,13 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
||||
</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-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-alert v-if="hintError" type="error" :title="hintError" class="mb-3" />
|
||||
<n-button
|
||||
v-if="!hintTarget && !hintLoading"
|
||||
type="primary"
|
||||
|
||||
@@ -14,20 +14,13 @@ import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
import { restartEditTrace, snapshotEditTrace } from "oj/problem/utils/editTrace"
|
||||
import {
|
||||
checkPythonSyntax,
|
||||
prefetchPythonSyntaxChecker,
|
||||
} from "oj/problem/utils/pythonSyntaxCheck"
|
||||
import { checkPythonSyntax, prefetchPythonSyntaxChecker } from "oj/problem/utils/pythonSyntaxCheck"
|
||||
|
||||
// ==================== 异步组件 ====================
|
||||
const ProblemReaction = defineAsyncComponent(
|
||||
() => import("./ProblemReaction.vue"),
|
||||
)
|
||||
const ProblemReaction = defineAsyncComponent(() => import("./ProblemReaction.vue"))
|
||||
// 结果面板第一次弹出(也就是第一次提交)时才加载:它带着 DataTable,而判题要等
|
||||
// 好几秒,这点下载时间藏得住。进页面就加载的话,只看题不提交的人也要付这笔
|
||||
const SubmissionResult = defineAsyncComponent(
|
||||
() => import("./SubmissionResult.vue"),
|
||||
)
|
||||
const SubmissionResult = defineAsyncComponent(() => import("./SubmissionResult.vue"))
|
||||
|
||||
// ==================== 基础状态 ====================
|
||||
const userStore = useUserStore()
|
||||
@@ -53,8 +46,7 @@ const { isDesktop } = useBreakpoints()
|
||||
const { celebrate } = useFireworks()
|
||||
|
||||
// ==================== 判题监控 ====================
|
||||
const { submission, judging, pending, submitting, startMonitoring } =
|
||||
useSubmissionMonitor()
|
||||
const { submission, judging, pending, submitting, startMonitoring } = useSubmissionMonitor()
|
||||
|
||||
const showResult = ref(false)
|
||||
const isFormatting = ref(false)
|
||||
@@ -68,8 +60,7 @@ watch(
|
||||
() => codeStore.code.language === "Python" && userStore.isAuthed,
|
||||
(needed) => {
|
||||
if (!needed) return
|
||||
if ("requestIdleCallback" in window)
|
||||
requestIdleCallback(prefetchPythonSyntaxChecker)
|
||||
if ("requestIdleCallback" in window) requestIdleCallback(prefetchPythonSyntaxChecker)
|
||||
else setTimeout(prefetchPythonSyntaxChecker, 1000)
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -162,8 +153,7 @@ async function submit() {
|
||||
code: codeStore.code.value,
|
||||
// 编辑过程信号,见 utils/editTrace.ts。协作的判断和 ProblemEditor 的 collabHere 同一个口径
|
||||
trace: snapshotEditTrace(
|
||||
collabStore.room !== null &&
|
||||
collabStore.room.problemId === problem.value!._id,
|
||||
collabStore.room !== null && collabStore.room.problemId === problem.value!._id,
|
||||
),
|
||||
}
|
||||
if (contestID) {
|
||||
@@ -219,11 +209,7 @@ watch(
|
||||
watch(
|
||||
() => submission.value?.result,
|
||||
async (result) => {
|
||||
if (
|
||||
result !== SubmissionStatus.accepted &&
|
||||
result !== SubmissionStatus.ast_check_failed
|
||||
)
|
||||
return
|
||||
if (result !== SubmissionStatus.accepted && result !== SubmissionStatus.ast_check_failed) return
|
||||
|
||||
// 1. 刷新题目状态
|
||||
problem.value!.myStatus = 0
|
||||
|
||||
@@ -10,10 +10,7 @@ import { sortFlowchartCriteria } from "utils/constants"
|
||||
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 { useFlowchartWebSocket, type FlowchartEvaluationUpdate } from "shared/composables/websocket"
|
||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||
|
||||
// API 和状态管理
|
||||
@@ -46,8 +43,7 @@ interface FlowchartEditorInstance {
|
||||
}
|
||||
|
||||
// 通过inject获取FlowchartEditor组件的引用
|
||||
const flowchartEditorRef =
|
||||
inject<Ref<FlowchartEditorInstance | null>>("flowchartEditorRef")
|
||||
const flowchartEditorRef = inject<Ref<FlowchartEditorInstance | null>>("flowchartEditorRef")
|
||||
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
||||
|
||||
// 基础组合式函数
|
||||
@@ -77,14 +73,10 @@ const evaluation = ref<Evaluation>({
|
||||
})
|
||||
const page = ref(1)
|
||||
const lastSubmittedMermaidCode = ref("")
|
||||
const suggestionLines = computed(() =>
|
||||
splitSuggestionLines(evaluation.value.suggestions),
|
||||
)
|
||||
const suggestionLines = computed(() => splitSuggestionLines(evaluation.value.suggestions))
|
||||
|
||||
// jsonb 不保留键序,直接遍历会把 40 分的「逻辑正确性」排到最后
|
||||
const sortedCriteria = computed(() =>
|
||||
sortFlowchartCriteria(evaluation.value.criteria_details),
|
||||
)
|
||||
const sortedCriteria = computed(() => sortFlowchartCriteria(evaluation.value.criteria_details))
|
||||
|
||||
function splitSuggestionLines(suggestions?: string | null) {
|
||||
return suggestions
|
||||
@@ -111,9 +103,7 @@ const POLL_INTERVAL = 3000
|
||||
/** 到点还没结果就收手,别无限轮询下去 */
|
||||
const POLL_TIMEOUT = 3 * 60 * 1000
|
||||
|
||||
type Outcome =
|
||||
| { ok: true; score: number; grade: string }
|
||||
| { ok: false; error?: string }
|
||||
type Outcome = { ok: true; score: number; grade: string } | { ok: false; error?: string }
|
||||
|
||||
const { pause: pausePolling, resume: resumePolling } = useIntervalFn(
|
||||
async () => {
|
||||
@@ -175,21 +165,12 @@ function settle(submissionId: string, outcome: Outcome) {
|
||||
loading.value = false
|
||||
|
||||
if (!outcome.ok) {
|
||||
message.error(
|
||||
outcome.error
|
||||
? `流程图评分失败: ${outcome.error}`
|
||||
: "流程图评分失败,请稍后重试",
|
||||
)
|
||||
message.error(outcome.error ? `流程图评分失败: ${outcome.error}` : "流程图评分失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
latestRating.value = { score: outcome.score, grade: outcome.grade }
|
||||
message.success(
|
||||
`流程图评分完成!得分: ${outcome.score}分 (${outcome.grade}级)`,
|
||||
)
|
||||
if (
|
||||
(outcome.grade === "A" || outcome.grade === "S") &&
|
||||
lastSubmittedMermaidCode.value
|
||||
) {
|
||||
message.success(`流程图评分完成!得分: ${outcome.score}分 (${outcome.grade}级)`)
|
||||
if ((outcome.grade === "A" || outcome.grade === "S") && lastSubmittedMermaidCode.value) {
|
||||
myFlowchartStore.show(lastSubmittedMermaidCode.value)
|
||||
}
|
||||
}
|
||||
@@ -208,9 +189,8 @@ const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
|
||||
}
|
||||
|
||||
// 创建 WebSocket 连接
|
||||
const { connect, disconnect, subscribe, unsubscribe } = useFlowchartWebSocket(
|
||||
handleWebSocketMessage,
|
||||
)
|
||||
const { connect, disconnect, subscribe, unsubscribe } =
|
||||
useFlowchartWebSocket(handleWebSocketMessage)
|
||||
|
||||
// 订阅提交更新,同时开启轮询兜底
|
||||
function subscribeToSubmission(submissionId: string) {
|
||||
@@ -292,10 +272,7 @@ async function getCurrentSubmission() {
|
||||
|
||||
async function getSubmission(submissionPage = 0) {
|
||||
if (!problem.value?.id) return
|
||||
const data = await getFlowchartSubmissionDetail(
|
||||
problem.value.id,
|
||||
submissionPage,
|
||||
)
|
||||
const data = await getFlowchartSubmissionDetail(problem.value.id, submissionPage)
|
||||
submissionCount.value = data.count
|
||||
const submission = data.submission
|
||||
// 翻到没有提交的页时后端返回 null(契约里 submission 是 nullable)——
|
||||
@@ -324,8 +301,7 @@ async function getSubmission(submissionPage = 0) {
|
||||
grade: (submission.aiGrade ?? "") as Rating["grade"],
|
||||
feedback: submission.aiFeedback ?? "",
|
||||
suggestions: submission.aiSuggestions ?? "",
|
||||
criteria_details:
|
||||
submission.aiCriteriaDetails as Evaluation["criteria_details"],
|
||||
criteria_details: submission.aiCriteriaDetails as Evaluation["criteria_details"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,18 +441,12 @@ onUnmounted(() => {
|
||||
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
||||
{{ renderError }}
|
||||
</n-alert>
|
||||
<div
|
||||
class="flowchart"
|
||||
v-show="!renderError"
|
||||
ref="mermaidContainer"
|
||||
></div>
|
||||
<div class="flowchart" v-show="!renderError" ref="mermaidContainer"></div>
|
||||
</n-spin>
|
||||
</div>
|
||||
<!-- 加载到编辑器按钮 -->
|
||||
<n-flex style="margin-top: 16px" justify="center">
|
||||
<n-button @click="loadToEditor" type="primary">
|
||||
加载到流程图编辑器
|
||||
</n-button>
|
||||
<n-button @click="loadToEditor" type="primary"> 加载到流程图编辑器 </n-button>
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
|
||||
@@ -511,23 +481,11 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 详细评分 -->
|
||||
<n-card v-if="sortedCriteria.length" size="small" title="详细评分">
|
||||
<div
|
||||
v-for="[key, detail] in sortedCriteria"
|
||||
:key="key"
|
||||
style="margin-bottom: 12px"
|
||||
>
|
||||
<div v-for="[key, detail] in sortedCriteria" :key="key" style="margin-bottom: 12px">
|
||||
<!-- 评分项标题和分数 -->
|
||||
<n-flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style="margin-bottom: 4px"
|
||||
>
|
||||
<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
|
||||
>
|
||||
<n-tag :type="getPercentType(detail.score / detail.max)" size="small" round>
|
||||
{{ detail.score || 0 }}分 / {{ detail.max }}分
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
@@ -541,16 +499,8 @@ onUnmounted(() => {
|
||||
</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 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>
|
||||
|
||||
@@ -21,13 +21,7 @@ export function getSubmitButtonState({
|
||||
isJudging,
|
||||
isCooldown,
|
||||
}: SubmitButtonStateInput): SubmitButtonState {
|
||||
const disabled =
|
||||
!isAuthed ||
|
||||
!hasCode ||
|
||||
isFormatting ||
|
||||
isSubmitting ||
|
||||
isJudging ||
|
||||
isCooldown
|
||||
const disabled = !isAuthed || !hasCode || isFormatting || isSubmitting || isJudging || isCooldown
|
||||
|
||||
let label = "提交代码"
|
||||
if (!isAuthed) {
|
||||
|
||||
@@ -35,14 +35,7 @@ function svgToBitmapShape(svg: string): Promise<confetti.Shape> {
|
||||
resolve({
|
||||
type: "bitmap",
|
||||
bitmap,
|
||||
matrix: [
|
||||
scale,
|
||||
0,
|
||||
0,
|
||||
scale,
|
||||
(-renderSize * scale) / 2,
|
||||
(-renderSize * scale) / 2,
|
||||
],
|
||||
matrix: [scale, 0, 0, scale, (-renderSize * scale) / 2, (-renderSize * scale) / 2],
|
||||
} as unknown as confetti.Shape)
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
@@ -176,14 +169,7 @@ export function useFireworks() {
|
||||
() => {
|
||||
const end = Date.now() + 2000
|
||||
|
||||
const colors = [
|
||||
"#ff595e",
|
||||
"#ff924c",
|
||||
"#ffca3a",
|
||||
"#8ac926",
|
||||
"#1982c4",
|
||||
"#6a4c93",
|
||||
]
|
||||
const colors = ["#ff595e", "#ff924c", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"]
|
||||
|
||||
const frame = () => {
|
||||
confetti({
|
||||
@@ -228,14 +214,7 @@ export function useFireworks() {
|
||||
x: Math.random(),
|
||||
y: Math.random() - 0.2,
|
||||
},
|
||||
colors: [
|
||||
"#ff0000",
|
||||
"#00ff00",
|
||||
"#0000ff",
|
||||
"#ffff00",
|
||||
"#ff00ff",
|
||||
"#00ffff",
|
||||
],
|
||||
colors: ["#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff", "#00ffff"],
|
||||
})
|
||||
}, 200)
|
||||
timers.add(interval)
|
||||
@@ -380,8 +359,7 @@ export function useFireworks() {
|
||||
]
|
||||
|
||||
// 随机选择一种效果
|
||||
const randomEffect =
|
||||
fireworkTypes[Math.floor(Math.random() * fireworkTypes.length)]
|
||||
const randomEffect = fireworkTypes[Math.floor(Math.random() * fireworkTypes.length)]
|
||||
randomEffect()
|
||||
}
|
||||
|
||||
|
||||
@@ -33,15 +33,12 @@ export function useMermaidConverter() {
|
||||
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, "_")
|
||||
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 = escapeLabel(
|
||||
node.data?.customLabel || node.data?.label || "节点",
|
||||
)
|
||||
const label = escapeLabel(node.data?.customLabel || node.data?.label || "节点")
|
||||
const originalType = node.data?.originalType || node.type
|
||||
|
||||
// 根据节点原始类型确定Mermaid语法
|
||||
@@ -90,20 +87,13 @@ export function useMermaidConverter() {
|
||||
|
||||
// 添加样式定义来区分不同类型的节点
|
||||
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 += " 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"
|
||||
|
||||
// 为节点应用样式
|
||||
|
||||
@@ -4,10 +4,7 @@ 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"
|
||||
import { useSubmissionWebSocket, type SubmissionUpdate } from "shared/composables/websocket"
|
||||
|
||||
/**
|
||||
* 判题监控 Composable
|
||||
@@ -29,10 +26,7 @@ export function useSubmissionMonitor() {
|
||||
|
||||
const result = res.result
|
||||
// 判题完成,停止轮询
|
||||
if (
|
||||
result !== SubmissionStatus.judging &&
|
||||
result !== SubmissionStatus.pending
|
||||
) {
|
||||
if (result !== SubmissionStatus.judging && result !== SubmissionStatus.pending) {
|
||||
pausePolling()
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -75,9 +69,7 @@ export function useSubmissionMonitor() {
|
||||
|
||||
// 判题完成或出错,获取完整详情
|
||||
if (data.status === "finished" || data.status === "error") {
|
||||
console.log(
|
||||
`[SubmissionMonitor] 判题${data.status === "finished" ? "完成" : "出错"}`,
|
||||
)
|
||||
console.log(`[SubmissionMonitor] 判题${data.status === "finished" ? "完成" : "出错"}`)
|
||||
|
||||
// 停止轮询(WebSocket已成功)
|
||||
pausePolling()
|
||||
@@ -93,13 +85,8 @@ export function useSubmissionMonitor() {
|
||||
}
|
||||
|
||||
// 初始化 WebSocket
|
||||
const {
|
||||
connect,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
scheduleDisconnect,
|
||||
cancelScheduledDisconnect,
|
||||
} = useSubmissionWebSocket(handleSubmissionUpdate)
|
||||
const { connect, subscribe, unsubscribe, scheduleDisconnect, cancelScheduledDisconnect } =
|
||||
useSubmissionWebSocket(handleSubmissionUpdate)
|
||||
|
||||
// ==================== 轮询保底启动 ====================
|
||||
const { start: startPollingFallback } = useTimeoutFn(
|
||||
@@ -143,17 +130,11 @@ export function useSubmissionMonitor() {
|
||||
}
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
const judging = computed(
|
||||
() => submission.value?.result === SubmissionStatus.judging,
|
||||
)
|
||||
const judging = computed(() => submission.value?.result === SubmissionStatus.judging)
|
||||
|
||||
const pending = computed(
|
||||
() => submission.value?.result === SubmissionStatus.pending,
|
||||
)
|
||||
const pending = computed(() => submission.value?.result === SubmissionStatus.pending)
|
||||
|
||||
const submitting = computed(
|
||||
() => submission.value?.result === SubmissionStatus.submitting,
|
||||
)
|
||||
const submitting = computed(() => submission.value?.result === SubmissionStatus.submitting)
|
||||
|
||||
const isProcessing = computed(() => {
|
||||
return judging.value || pending.value || submitting.value
|
||||
|
||||
@@ -13,27 +13,13 @@ const loadProblemEditor = () => import("./components/ProblemEditor.vue")
|
||||
const loadContestEditor = () => import("./components/BasicEditor.vue")
|
||||
const ProblemEditor = defineAsyncComponent(loadProblemEditor)
|
||||
const BasicEditor = 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"),
|
||||
)
|
||||
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
|
||||
@@ -92,9 +78,7 @@ watch(
|
||||
([rawTab]) => {
|
||||
const tabs = tabOptions.value
|
||||
const fallback = tabs[0] ?? "content"
|
||||
currentTab.value = tabs.includes(rawTab as string)
|
||||
? (rawTab as string)
|
||||
: fallback
|
||||
currentTab.value = tabs.includes(rawTab as string) ? (rawTab as string) : fallback
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -189,26 +173,13 @@ watch(
|
||||
<n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId">
|
||||
<ProblemInfo />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="!contestID"
|
||||
name="comment"
|
||||
tab="题目点评"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<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="我的流程图"
|
||||
>
|
||||
<n-tab-pane v-if="myFlowchartStore.showing" name="my-flowchart" tab="我的流程图">
|
||||
<MyFlowchartTab />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
name="submission"
|
||||
tab="我的提交"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<n-tab-pane name="submission" tab="我的提交" :disabled="!!problemSetId">
|
||||
<ProblemSubmission />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
@@ -237,26 +208,13 @@ watch(
|
||||
<n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId">
|
||||
<ProblemInfo />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="!contestID"
|
||||
name="comment"
|
||||
tab="题目点评"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<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="我的流程图"
|
||||
>
|
||||
<n-tab-pane v-if="myFlowchartStore.showing" name="my-flowchart" tab="我的流程图">
|
||||
<MyFlowchartTab />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
name="submission"
|
||||
tab="我的提交"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<n-tab-pane name="submission" tab="我的提交" :disabled="!!problemSetId">
|
||||
<ProblemSubmission />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
@@ -277,19 +235,10 @@ watch(
|
||||
<n-tab-pane name="info" tab="统计" :disabled="!!problemSetId">
|
||||
<ProblemInfo />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="!contestID"
|
||||
name="comment"
|
||||
tab="点评"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<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="我的流程图"
|
||||
>
|
||||
<n-tab-pane v-if="myFlowchartStore.showing" name="my-flowchart" tab="我的流程图">
|
||||
<MyFlowchartTab />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="submission" tab="提交" :disabled="!!problemSetId">
|
||||
|
||||
@@ -122,14 +122,7 @@ watchDebounced(() => query.keyword, listProblems, {
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
() => [
|
||||
query.tag,
|
||||
query.difficulty,
|
||||
query.limit,
|
||||
query.page,
|
||||
query.author,
|
||||
query.sort,
|
||||
],
|
||||
() => [query.tag, query.difficulty, query.limit, query.page, query.author, query.sort],
|
||||
listProblems,
|
||||
)
|
||||
|
||||
@@ -174,18 +167,12 @@ const baseColumns: DataTableColumn<ProblemRow>[] = [
|
||||
render: (row) => h(ProblemStatus, { status: row.status }),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"编号",
|
||||
"streamline-ultimate-color:board-game-dice-1",
|
||||
),
|
||||
title: renderTableTitle("编号", "streamline-ultimate-color:board-game-dice-1"),
|
||||
key: "_id",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"题目",
|
||||
"streamline-ultimate-color:fruit-watermelon",
|
||||
),
|
||||
title: renderTableTitle("题目", "streamline-ultimate-color:fruit-watermelon"),
|
||||
key: "title",
|
||||
minWidth: 200,
|
||||
render: (row) => h(ProblemListTitle, { problem: row }),
|
||||
@@ -195,16 +182,13 @@ const baseColumns: DataTableColumn<ProblemRow>[] = [
|
||||
key: "difficulty",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
row.difficulty
|
||||
? h(NTag, { type: getTagColor(row.difficulty) }, () => row.difficulty)
|
||||
: null,
|
||||
row.difficulty ? h(NTag, { type: getTagColor(row.difficulty) }, () => row.difficulty) : null,
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("标签", "streamline-ultimate-color:attachment"),
|
||||
key: "tags",
|
||||
width: 260,
|
||||
render: (row) =>
|
||||
h(NFlex, () => row.tags.map((t) => h(NTag, { key: t }, () => t))),
|
||||
render: (row) => h(NFlex, () => row.tags.map((t) => h(NTag, { key: t }, () => t))),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("出题者", "streamline-emojis:man-raising-hand-2"),
|
||||
@@ -226,9 +210,7 @@ const baseColumns: DataTableColumn<ProblemRow>[] = [
|
||||
]
|
||||
|
||||
const columns = computed(() =>
|
||||
userStore.isAuthed
|
||||
? baseColumns
|
||||
: baseColumns.filter((c: any) => c.key !== "status"),
|
||||
userStore.isAuthed ? baseColumns : baseColumns.filter((c: any) => c.key !== "status"),
|
||||
)
|
||||
|
||||
function rowProps(row: ProblemRow) {
|
||||
@@ -280,11 +262,7 @@ function rowProps(row: ProblemRow) {
|
||||
<n-button @click="clearQuery" quaternary>重置</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button
|
||||
@click="toggleShowTag()"
|
||||
quaternary
|
||||
icon-placement="right"
|
||||
>
|
||||
<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>
|
||||
@@ -310,18 +288,9 @@ function rowProps(row: ProblemRow) {
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
</n-collapse-transition>
|
||||
<n-data-table
|
||||
:bordered="false"
|
||||
:data="problems"
|
||||
:columns="columns"
|
||||
:row-props="rowProps"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -82,8 +82,7 @@ export function restartEditTrace(len: number) {
|
||||
|
||||
function touch() {
|
||||
const now = performance.now()
|
||||
if (lastEditAt !== null && now - lastEditAt <= IDLE_MS)
|
||||
activeMs += now - lastEditAt
|
||||
if (lastEditAt !== null && now - lastEditAt <= IDLE_MS) activeMs += now - lastEditAt
|
||||
lastEditAt = now
|
||||
}
|
||||
|
||||
@@ -103,8 +102,7 @@ export const editTraceExtensions = [
|
||||
for (const tr of update.transactions) {
|
||||
if (!tr.docChanged) continue
|
||||
// 顺序要紧:isUserEvent("input") 也会匹配 "input.paste"
|
||||
const pasted =
|
||||
tr.isUserEvent("input.paste") || tr.isUserEvent("input.drop")
|
||||
const pasted = tr.isUserEvent("input.paste") || tr.isUserEvent("input.drop")
|
||||
const typed = !pasted && tr.isUserEvent("input")
|
||||
const deleted = tr.isUserEvent("delete")
|
||||
if (!pasted && !typed && !deleted) continue
|
||||
@@ -115,10 +113,7 @@ export const editTraceExtensions = [
|
||||
// 原样替换不算:closeBrackets 越过已有的右括号 / 引号时,是把 `)` 替换成 `)`
|
||||
// 而不是只挪光标(@codemirror/autocomplete 的 handleClose),不排掉的话
|
||||
// 每敲一个右括号就多记一个键入加一个删除
|
||||
if (
|
||||
toA - fromA === text.length &&
|
||||
tr.startState.sliceDoc(fromA, toA) === text.toString()
|
||||
)
|
||||
if (toA - fromA === text.length && tr.startState.sliceDoc(fromA, toA) === text.toString())
|
||||
return
|
||||
removed += toA - fromA
|
||||
inserted += text.length
|
||||
|
||||
@@ -31,9 +31,7 @@ export function prefetchPythonSyntaxChecker() {
|
||||
* 只编译不执行,不受 input() 等 IO 调用影响。
|
||||
* 加载失败时返回 null(放行提交),交给后端判题兜底。
|
||||
*/
|
||||
export async function checkPythonSyntax(
|
||||
code: string,
|
||||
): Promise<PythonSyntaxError | null> {
|
||||
export async function checkPythonSyntax(code: string): Promise<PythonSyntaxError | null> {
|
||||
let Sk: any
|
||||
try {
|
||||
Sk = await loadSkulpt()
|
||||
|
||||
@@ -42,15 +42,11 @@ function getProgressPercentage() {
|
||||
|
||||
// 有选做题时把「必做 N 题」标出来,否则「共 10 道题目」和「9 / 9」对不上
|
||||
const optionalCount = computed(
|
||||
() =>
|
||||
props.problemSet.problemsCount -
|
||||
(props.problemSet.userProgress?.totalCount ?? 0),
|
||||
() => props.problemSet.problemsCount - (props.problemSet.userProgress?.totalCount ?? 0),
|
||||
)
|
||||
|
||||
const endTimeText = computed(() =>
|
||||
props.problemSet.endTime
|
||||
? parseTime(props.problemSet.endTime, "YYYY-MM-DD HH:mm")
|
||||
: "",
|
||||
props.problemSet.endTime ? parseTime(props.problemSet.endTime, "YYYY-MM-DD HH:mm") : "",
|
||||
)
|
||||
|
||||
function handleJoin() {
|
||||
@@ -62,9 +58,7 @@ function handleJoin() {
|
||||
<n-card style="margin-bottom: 24px">
|
||||
<n-flex justify="space-between" align="center">
|
||||
<n-flex align="center">
|
||||
<n-tag type="warning" v-if="problemSet.status === 'archived'">
|
||||
已归档
|
||||
</n-tag>
|
||||
<n-tag type="warning" v-if="problemSet.status === 'archived'"> 已归档 </n-tag>
|
||||
<n-tag :type="getDifficultyTag(problemSet.difficulty).type">
|
||||
{{ getDifficultyTag(problemSet.difficulty).text }}
|
||||
</n-tag>
|
||||
@@ -89,11 +83,7 @@ function handleJoin() {
|
||||
<!-- 用户徽章显示区域 - 只在已加入且有徽章时显示 -->
|
||||
<n-flex v-if="isJoined && userBadges.length > 0" align="center">
|
||||
<n-text>已获徽章</n-text>
|
||||
<UserBadge
|
||||
v-for="badge in userBadges"
|
||||
:key="badge.id"
|
||||
:badge="badge"
|
||||
/>
|
||||
<UserBadge v-for="badge in userBadges" :key="badge.id" :badge="badge" />
|
||||
</n-flex>
|
||||
|
||||
<!-- 完成进度 - 只在已加入时显示 -->
|
||||
@@ -103,9 +93,7 @@ function handleJoin() {
|
||||
{{ problemSet.userProgress?.completedCount ?? 0 }} /
|
||||
{{ problemSet.userProgress?.totalCount ?? 0 }}
|
||||
</n-text>
|
||||
<n-text depth="3" v-if="optionalCount > 0">
|
||||
(另有 {{ optionalCount }} 道选做)
|
||||
</n-text>
|
||||
<n-text depth="3" v-if="optionalCount > 0"> (另有 {{ optionalCount }} 道选做) </n-text>
|
||||
</n-flex>
|
||||
<n-progress
|
||||
v-if="isJoined"
|
||||
|
||||
@@ -27,10 +27,7 @@ function handleProblemClick(problemId: string) {
|
||||
<template>
|
||||
<div>
|
||||
<n-grid :cols="isDesktop ? 4 : 1" :x-gap="16" :y-gap="16">
|
||||
<n-grid-item
|
||||
v-for="(problemSetProblem, index) in problems"
|
||||
:key="problemSetProblem.id"
|
||||
>
|
||||
<n-grid-item v-for="(problemSetProblem, index) in problems" :key="problemSetProblem.id">
|
||||
<n-card
|
||||
hoverable
|
||||
@click="handleProblemClick(problemSetProblem.problem._id)"
|
||||
|
||||
@@ -21,9 +21,7 @@ const statistics = ref<{
|
||||
avgProgress: number
|
||||
} | null>(null)
|
||||
const classFilter = ref<string>("")
|
||||
const completionFilter = ref<"" | "completed" | "in_progress" | "not_started">(
|
||||
"",
|
||||
)
|
||||
const completionFilter = ref<"" | "completed" | "in_progress" | "not_started">("")
|
||||
const allProblems = ref<Array<{ id: number; _id: string; title: string }>>([])
|
||||
|
||||
// 完成度筛选选项
|
||||
@@ -136,8 +134,7 @@ const progressColumns = [
|
||||
title: "加入时间",
|
||||
key: "join_time",
|
||||
width: 180,
|
||||
render: (row: ProblemSetProgress) =>
|
||||
parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
render: (row: ProblemSetProgress) => parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "已完成数量",
|
||||
@@ -153,12 +150,8 @@ const progressColumns = [
|
||||
return "全部题目已完成"
|
||||
}
|
||||
if (row.progressPercentage > 50 && row.progressPercentage < 100) {
|
||||
const completedProblemIds = new Set(
|
||||
row.completedProblems.map((p: any) => p.id),
|
||||
)
|
||||
const incompleteProblems = allProblems.value.filter(
|
||||
(p) => !completedProblemIds.has(p.id),
|
||||
)
|
||||
const completedProblemIds = new Set(row.completedProblems.map((p: any) => p.id))
|
||||
const incompleteProblems = allProblems.value.filter((p) => !completedProblemIds.has(p.id))
|
||||
return h("div", { style: "max-height: 120px; overflow-y: auto" }, [
|
||||
h(NFlex, {}, () =>
|
||||
incompleteProblems.map((problem) =>
|
||||
@@ -249,10 +242,7 @@ const progressColumns = [
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card size="small">
|
||||
<n-statistic
|
||||
label="平均进度"
|
||||
:value="stats.avgProgress.toFixed(0) + '%'"
|
||||
/>
|
||||
<n-statistic label="平均进度" :value="stats.avgProgress.toFixed(0) + '%'" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { errorMessage } from "utils/api"
|
||||
import {
|
||||
getProblemSetDetail,
|
||||
getProblemSetProblems,
|
||||
joinProblemSet,
|
||||
getUserBadges,
|
||||
} from "../api"
|
||||
import type {
|
||||
ProblemSet,
|
||||
ProblemSetProblem,
|
||||
UserBadge as UserBadgeType,
|
||||
} from "utils/types"
|
||||
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet, getUserBadges } from "../api"
|
||||
import type { ProblemSet, ProblemSetProblem, UserBadge as UserBadgeType } from "utils/types"
|
||||
import { useFireworks } from "../problem/composables/useFireworks"
|
||||
import ProblemSetHeader from "./components/ProblemSetHeader.vue"
|
||||
import ProblemSetProblemsList from "./components/ProblemSetProblemsList.vue"
|
||||
|
||||
@@ -52,10 +52,7 @@ function goToProblemSet(problemSetId: number) {
|
||||
router.push(`/problemset/${problemSetId}`)
|
||||
}
|
||||
|
||||
function getConditionText(
|
||||
conditionType: string,
|
||||
conditionValue: number,
|
||||
): string {
|
||||
function getConditionText(conditionType: string, conditionValue: number): string {
|
||||
const conditionMap: Record<string, string> = {
|
||||
all_problems: "完成所有题目",
|
||||
problem_count: `完成 ${conditionValue} 道题目`,
|
||||
@@ -97,18 +94,9 @@ watch(() => [query.page, query.limit], listProblemSets)
|
||||
/>
|
||||
</n-space>
|
||||
|
||||
<n-grid
|
||||
v-if="problemSets.length > 0"
|
||||
:cols="isDesktop ? 3 : 1"
|
||||
:x-gap="16"
|
||||
:y-gap="16"
|
||||
>
|
||||
<n-grid v-if="problemSets.length > 0" :cols="isDesktop ? 3 : 1" :x-gap="16" :y-gap="16">
|
||||
<n-grid-item v-for="problemSet in problemSets" :key="problemSet.id">
|
||||
<n-card
|
||||
hoverable
|
||||
@click="goToProblemSet(problemSet.id)"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<n-card hoverable @click="goToProblemSet(problemSet.id)" style="cursor: pointer">
|
||||
<template #header>
|
||||
<n-flex justify="space-between" align="center">
|
||||
<n-text strong>{{ problemSet.title }}</n-text>
|
||||
@@ -127,36 +115,22 @@ watch(() => [query.page, query.limit], listProblemSets)
|
||||
<n-flex align="center" style="height: 28px">
|
||||
<!-- 用户进度显示 -->
|
||||
<n-progress
|
||||
v-if="
|
||||
problemSet.userProgress?.isJoined &&
|
||||
!problemSet.userProgress?.isCompleted
|
||||
"
|
||||
v-if="problemSet.userProgress?.isJoined && !problemSet.userProgress?.isCompleted"
|
||||
type="line"
|
||||
:percentage="
|
||||
Math.round(problemSet.userProgress.progressPercentage)
|
||||
"
|
||||
:percentage="Math.round(problemSet.userProgress.progressPercentage)"
|
||||
:height="4"
|
||||
:border-radius="2"
|
||||
style="width: 100px"
|
||||
:color="
|
||||
getProgressColor(problemSet.userProgress.progressPercentage)
|
||||
"
|
||||
:color="getProgressColor(problemSet.userProgress.progressPercentage)"
|
||||
/>
|
||||
<n-tag type="warning" v-if="problemSet.status === 'archived'">
|
||||
已归档
|
||||
</n-tag>
|
||||
<n-tag type="warning" v-if="problemSet.status === 'archived'"> 已归档 </n-tag>
|
||||
<n-tag
|
||||
v-if="
|
||||
problemSet.userProgress?.isJoined &&
|
||||
!problemSet.userProgress?.isCompleted
|
||||
"
|
||||
v-if="problemSet.userProgress?.isJoined && !problemSet.userProgress?.isCompleted"
|
||||
type="warning"
|
||||
>
|
||||
已加入
|
||||
</n-tag>
|
||||
<n-tag v-if="problemSet.userProgress?.isCompleted" type="error">
|
||||
已完成
|
||||
</n-tag>
|
||||
<n-tag v-if="problemSet.userProgress?.isCompleted" type="error"> 已完成 </n-tag>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
|
||||
@@ -167,11 +141,7 @@ watch(() => [query.page, query.limit], listProblemSets)
|
||||
{{ parseTime(problemSet.createTime, "YYYY-MM-DD") }}
|
||||
</n-text>
|
||||
<n-flex>
|
||||
<n-tooltip
|
||||
v-for="badge in problemSet.badges"
|
||||
:key="badge.id"
|
||||
trigger="hover"
|
||||
>
|
||||
<n-tooltip v-for="badge in problemSet.badges" :key="badge.id" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-image
|
||||
:src="badge.icon"
|
||||
@@ -183,21 +153,12 @@ watch(() => [query.page, query.limit], listProblemSets)
|
||||
/>
|
||||
</template>
|
||||
<n-flex vertical size="small">
|
||||
<span style="font-weight: bold">
|
||||
徽章: {{ badge.name }}
|
||||
</span>
|
||||
<span style="font-weight: bold"> 徽章: {{ badge.name }} </span>
|
||||
<span>
|
||||
获取条件:
|
||||
{{
|
||||
getConditionText(
|
||||
badge.conditionType,
|
||||
badge.conditionValue,
|
||||
)
|
||||
}}
|
||||
{{ getConditionText(badge.conditionType, badge.conditionValue) }}
|
||||
</span>
|
||||
<n-text type="primary" v-if="badge.isEarned">
|
||||
✓ 已获得
|
||||
</n-text>
|
||||
<n-text type="primary" v-if="badge.isEarned"> ✓ 已获得 </n-text>
|
||||
</n-flex>
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
|
||||
@@ -14,15 +14,7 @@ import { ChartType } from "utils/constants"
|
||||
import type { Rank } from "utils/types"
|
||||
|
||||
// 仅注册柱状图所需的 Chart.js 组件
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
)
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, Colors)
|
||||
|
||||
const props = defineProps<{ rankData: Rank[]; type: ChartType }>()
|
||||
|
||||
|
||||
+40
-133
@@ -33,13 +33,10 @@ import { useAIStream } from "shared/composables/aiStream"
|
||||
|
||||
const GRADES = [20, 25]
|
||||
|
||||
const gradeOptions = Array.from(
|
||||
{ length: GRADES[1] - GRADES[0] + 1 },
|
||||
(_, i) => ({
|
||||
label: `${GRADES[1] - i}年级`,
|
||||
value: GRADES[1] - i,
|
||||
}),
|
||||
)
|
||||
const gradeOptions = Array.from({ length: GRADES[1] - GRADES[0] + 1 }, (_, i) => ({
|
||||
label: `${GRADES[1] - i}年级`,
|
||||
value: GRADES[1] - i,
|
||||
}))
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
@@ -87,15 +84,12 @@ const weeklyStart = ref("")
|
||||
* 我入不入这张榜。教师/超管本来就不参与排名(服务端 me 恒为 null),未登录同理 ——
|
||||
* 这两种情况下 footer 那句「做出 1 题就能上榜」是说给不相干的人听的,不该出现。
|
||||
*/
|
||||
const weeklyMeEligible = computed(
|
||||
() => userStore.isAuthed && !userStore.isTeacherOrAbove,
|
||||
)
|
||||
const weeklyMeEligible = computed(() => userStore.isAuthed && !userStore.isTeacherOrAbove)
|
||||
/** 我在榜面之外(或本周还没做出题)—— 榜上高亮不到我,footer 另起一行 */
|
||||
const weeklyMeOffBoard = computed(
|
||||
() =>
|
||||
weeklyMeEligible.value &&
|
||||
(!weeklyMe.value ||
|
||||
!weeklyData.value.some((row) => row.rank === weeklyMe.value!.rank)),
|
||||
(!weeklyMe.value || !weeklyData.value.some((row) => row.rank === weeklyMe.value!.rank)),
|
||||
)
|
||||
|
||||
const showClassDetailModal = ref(false)
|
||||
@@ -161,14 +155,10 @@ const columns: DataTableColumn<Rank>[] = [
|
||||
key: "index",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (_, index) =>
|
||||
h(RankMedal, { index, page: query.page, limit: query.limit }),
|
||||
render: (_, index) => h(RankMedal, { index, page: query.page, limit: query.limit }),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"用户",
|
||||
"streamline-emojis:smiling-face-with-sunglasses",
|
||||
),
|
||||
title: renderTableTitle("用户", "streamline-emojis:smiling-face-with-sunglasses"),
|
||||
key: "username",
|
||||
width: 240,
|
||||
render: (row) =>
|
||||
@@ -177,8 +167,7 @@ const columns: DataTableColumn<Rank>[] = [
|
||||
row.isOnline
|
||||
? h("span", {
|
||||
title: "在线(5 分钟内有活动)",
|
||||
style:
|
||||
"width:8px;height:8px;border-radius:50%;background:#18a058;flex:none",
|
||||
style: "width:8px;height:8px;border-radius:50%;background:#18a058;flex:none",
|
||||
})
|
||||
: null,
|
||||
h(
|
||||
@@ -190,27 +179,21 @@ const columns: DataTableColumn<Rank>[] = [
|
||||
},
|
||||
() => row.user.username,
|
||||
),
|
||||
isMe(row)
|
||||
? h(Icon, { width: 20, icon: "fluent-emoji:person-raising-hand" })
|
||||
: null,
|
||||
isMe(row) ? h(Icon, { width: 20, icon: "fluent-emoji:person-raising-hand" }) : null,
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "tiny",
|
||||
title: "查看成就",
|
||||
onClick: () =>
|
||||
router.push("/achievement?name=" + row.user.username),
|
||||
onClick: () => router.push("/achievement?name=" + row.user.username),
|
||||
},
|
||||
() => "🏆",
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"个性签名",
|
||||
"streamline-emojis:no-one-under-eighteen",
|
||||
),
|
||||
title: renderTableTitle("个性签名", "streamline-emojis:no-one-under-eighteen"),
|
||||
key: "mood",
|
||||
minWidth: 200,
|
||||
},
|
||||
@@ -221,10 +204,7 @@ const columns: DataTableColumn<Rank>[] = [
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"提交数",
|
||||
"streamline-ultimate-color:space-rocket-earth",
|
||||
),
|
||||
title: renderTableTitle("提交数", "streamline-ultimate-color:space-rocket-earth"),
|
||||
key: "submissionNumber",
|
||||
width: 120,
|
||||
align: "center",
|
||||
@@ -274,9 +254,7 @@ const options: SelectOption[] = [...LONG_DURATION_OPTIONS]
|
||||
|
||||
// 认不出来退回 options[1](一个月内),和 duration 的初值一致
|
||||
const subOptions = computed<Duration>(
|
||||
() =>
|
||||
durationFromValue(duration.value) ??
|
||||
durationFromValue(LONG_DURATION_OPTIONS[1]!.value)!,
|
||||
() => durationFromValue(duration.value) ?? durationFromValue(LONG_DURATION_OPTIONS[1]!.value)!,
|
||||
)
|
||||
|
||||
// 周榜占的是三栏里的一栏,列头不再重复「本周」(卡片标题已经写着),
|
||||
@@ -291,10 +269,7 @@ const weeklyColumns: DataTableColumn<WeeklyRankItem>[] = [
|
||||
render: (row) => h(RankMedal, { index: row.rank - 1, page: 1, limit: 10 }),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"用户",
|
||||
"streamline-emojis:smiling-face-with-sunglasses",
|
||||
),
|
||||
title: renderTableTitle("用户", "streamline-emojis:smiling-face-with-sunglasses"),
|
||||
key: "username",
|
||||
minWidth: 120,
|
||||
ellipsis: { tooltip: true },
|
||||
@@ -354,8 +329,7 @@ const classColumns: DataTableColumn<ClassRank>[] = [
|
||||
{
|
||||
title: "班级",
|
||||
key: "class_name",
|
||||
render: (row) =>
|
||||
`${row.className.slice(0, 2)}计算机${row.className.slice(2)}班`,
|
||||
render: (row) => `${row.className.slice(0, 2)}计算机${row.className.slice(2)}班`,
|
||||
minWidth: 120,
|
||||
titleAlign: "center",
|
||||
align: "center",
|
||||
@@ -492,10 +466,7 @@ async function listClassRank() {
|
||||
|
||||
async function listMyClassRank() {
|
||||
try {
|
||||
const offset =
|
||||
myClassScope.value === "all"
|
||||
? (myClassQuery.page - 1) * myClassQuery.limit
|
||||
: 0
|
||||
const offset = myClassScope.value === "all" ? (myClassQuery.page - 1) * myClassQuery.limit : 0
|
||||
const limit = myClassScope.value === "all" ? myClassQuery.limit : undefined
|
||||
const res = await getUserClassRank(myClassScope.value, offset, limit)
|
||||
myRank.value = res.myRank
|
||||
@@ -560,11 +531,7 @@ watch(
|
||||
<template #header>
|
||||
<div style="height: 34px">全服 Top10</div>
|
||||
</template>
|
||||
<Chart
|
||||
v-if="rankChart.length"
|
||||
:type="ChartType.Rank"
|
||||
:rank-data="rankChart"
|
||||
/>
|
||||
<Chart v-if="rankChart.length" :type="ChartType.Rank" :rank-data="rankChart" />
|
||||
<n-empty v-else style="padding: 20px 0"></n-empty>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
@@ -572,11 +539,7 @@ watch(
|
||||
<n-card :bordered="false">
|
||||
<template #header>日活 Top10</template>
|
||||
<template #header-extra>
|
||||
<n-select
|
||||
style="width: 120px"
|
||||
:options="options"
|
||||
v-model:value="duration"
|
||||
/>
|
||||
<n-select style="width: 120px" :options="options" v-model:value="duration" />
|
||||
</template>
|
||||
<Chart
|
||||
v-if="activityChart.length"
|
||||
@@ -596,20 +559,11 @@ watch(
|
||||
<n-card :bordered="false">
|
||||
<template #header>全服 Top100</template>
|
||||
<template #header-extra>
|
||||
<n-tag
|
||||
v-if="onlineCount > 0"
|
||||
round
|
||||
:bordered="false"
|
||||
type="success"
|
||||
>
|
||||
<n-tag v-if="onlineCount > 0" round :bordered="false" type="success">
|
||||
当前在线 {{ onlineCount }} 人
|
||||
</n-tag>
|
||||
</template>
|
||||
<n-data-table
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:row-class-name="rowClassName"
|
||||
/>
|
||||
<n-data-table :data="data" :columns="columns" :row-class-name="rowClassName" />
|
||||
<template #footer>
|
||||
<n-flex align="center" justify="space-between" :wrap="false">
|
||||
<!-- 前 100 名之外的学生榜上找不到自己,这里单独给一行 -->
|
||||
@@ -617,17 +571,12 @@ watch(
|
||||
<template #icon>
|
||||
<Icon width="18" icon="fluent-emoji:person-raising-hand" />
|
||||
</template>
|
||||
我的排名:第 {{ me!.rank }} 名 · 已解决
|
||||
{{ me!.acceptedNumber }} · 提交 {{ me!.submissionNumber }} ·
|
||||
正确率
|
||||
我的排名:第 {{ me!.rank }} 名 · 已解决 {{ me!.acceptedNumber }} · 提交
|
||||
{{ me!.submissionNumber }} · 正确率
|
||||
{{ getACRate(me!.acceptedNumber, me!.submissionNumber) }}
|
||||
</n-tag>
|
||||
<span v-else />
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="query.page"
|
||||
v-model:limit="query.limit"
|
||||
/>
|
||||
<Pagination :total="total" v-model:page="query.page" v-model:limit="query.limit" />
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-card>
|
||||
@@ -659,8 +608,7 @@ watch(
|
||||
:data="weeklyData"
|
||||
:columns="weeklyColumns"
|
||||
:row-class-name="
|
||||
(row: WeeklyRankItem) =>
|
||||
weeklyMe && row.rank === weeklyMe.rank ? 'me-row' : ''
|
||||
(row: WeeklyRankItem) => (weeklyMe && row.rank === weeklyMe.rank ? 'me-row' : '')
|
||||
"
|
||||
/>
|
||||
<n-empty
|
||||
@@ -678,12 +626,10 @@ watch(
|
||||
<Icon width="18" icon="fluent-emoji:person-raising-hand" />
|
||||
</template>
|
||||
<template v-if="weeklyMe">
|
||||
我这周第 {{ weeklyMe.rank }} 名(共 {{ weeklyTotal }} 人上榜)·
|
||||
新解决 {{ weeklyMe.solvedCount }} 题
|
||||
</template>
|
||||
<template v-else>
|
||||
我这周还没有解决新题目,做出 1 题就能上榜
|
||||
我这周第 {{ weeklyMe.rank }} 名(共 {{ weeklyTotal }} 人上榜)· 新解决
|
||||
{{ weeklyMe.solvedCount }} 题
|
||||
</template>
|
||||
<template v-else> 我这周还没有解决新题目,做出 1 题就能上榜 </template>
|
||||
</n-tag>
|
||||
</template>
|
||||
</n-card>
|
||||
@@ -776,10 +722,7 @@ watch(
|
||||
class="stat-avg-ac"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon
|
||||
icon="streamline-ultimate-color:analytics-pie-2"
|
||||
width="20"
|
||||
/>
|
||||
<Icon icon="streamline-ultimate-color:analytics-pie-2" width="20" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
@@ -791,10 +734,7 @@ watch(
|
||||
class="stat-median-ac"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon
|
||||
icon="streamline-ultimate-color:cursor-target-1"
|
||||
width="20"
|
||||
/>
|
||||
<Icon icon="streamline-ultimate-color:cursor-target-1" width="20" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
@@ -806,10 +746,7 @@ watch(
|
||||
class="stat-total-submission"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon
|
||||
icon="streamline-ultimate-color:common-file-text"
|
||||
width="20"
|
||||
/>
|
||||
<Icon icon="streamline-ultimate-color:common-file-text" width="20" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
@@ -829,12 +766,7 @@ watch(
|
||||
|
||||
<n-divider style="margin: 12px 0" />
|
||||
|
||||
<n-descriptions
|
||||
bordered
|
||||
:column="2"
|
||||
size="small"
|
||||
label-placement="left"
|
||||
>
|
||||
<n-descriptions bordered :column="2" size="small" label-placement="left">
|
||||
<n-descriptions-item label="第一四分位数(Q1)">
|
||||
<span style="color: #9254de; font-weight: 500">{{
|
||||
classDetailData.q1Ac.toFixed(2)
|
||||
@@ -871,9 +803,7 @@ watch(
|
||||
}}</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="人数">
|
||||
<span style="color: #1890ff; font-weight: 600">{{
|
||||
classDetailData.userCount
|
||||
}}</span>
|
||||
<span style="color: #1890ff; font-weight: 600">{{ classDetailData.userCount }}</span>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
@@ -885,10 +815,7 @@ watch(
|
||||
:show-indicator="true"
|
||||
:border-radius="4"
|
||||
>
|
||||
<template #default
|
||||
>优秀率:
|
||||
{{ classDetailData.excellentRate.toFixed(1) }}%</template
|
||||
>
|
||||
<template #default>优秀率: {{ classDetailData.excellentRate.toFixed(1) }}%</template>
|
||||
</n-progress>
|
||||
<n-progress
|
||||
type="line"
|
||||
@@ -897,9 +824,7 @@ watch(
|
||||
:border-radius="4"
|
||||
status="success"
|
||||
>
|
||||
<template #default
|
||||
>及格率: {{ classDetailData.passRate.toFixed(1) }}%</template
|
||||
>
|
||||
<template #default>及格率: {{ classDetailData.passRate.toFixed(1) }}%</template>
|
||||
</n-progress>
|
||||
<n-progress
|
||||
type="line"
|
||||
@@ -908,27 +833,16 @@ watch(
|
||||
:border-radius="4"
|
||||
status="info"
|
||||
>
|
||||
<template #default
|
||||
>参与度: {{ classDetailData.activeRate.toFixed(1) }}%</template
|
||||
>
|
||||
<template #default>参与度: {{ classDetailData.activeRate.toFixed(1) }}%</template>
|
||||
</n-progress>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-flex
|
||||
justify="center"
|
||||
align="center"
|
||||
:size="12"
|
||||
style="margin-top: 12px"
|
||||
>
|
||||
<n-flex justify="center" align="center" :size="12" style="margin-top: 12px">
|
||||
<n-tag type="success" size="large">
|
||||
综合分: {{ classDetailData.compositeScore.toFixed(1) }}
|
||||
</n-tag>
|
||||
<n-button
|
||||
type="info"
|
||||
:loading="classDetailAiLoading"
|
||||
@click="analyzeSingleClassWithAI"
|
||||
>
|
||||
<n-button type="info" :loading="classDetailAiLoading" @click="analyzeSingleClassWithAI">
|
||||
<template #icon>
|
||||
<Icon icon="ph:sparkle" />
|
||||
</template>
|
||||
@@ -936,11 +850,7 @@ watch(
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-empty
|
||||
v-else-if="!classDetailLoading"
|
||||
description="暂无数据"
|
||||
style="padding: 40px 0"
|
||||
/>
|
||||
<n-empty v-else-if="!classDetailLoading" description="暂无数据" style="padding: 40px 0" />
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
|
||||
@@ -952,10 +862,7 @@ watch(
|
||||
>
|
||||
<n-spin :show="classDetailAiLoading" :delay="50">
|
||||
<div style="min-height: 200px">
|
||||
<MdPreview
|
||||
v-if="classDetailAiContent"
|
||||
:model-value="classDetailAiContent"
|
||||
/>
|
||||
<MdPreview v-if="classDetailAiContent" :model-value="classDetailAiContent" />
|
||||
<n-flex
|
||||
v-else-if="!classDetailAiLoading"
|
||||
align="center"
|
||||
|
||||
@@ -50,11 +50,7 @@ export const useAIStore = defineStore("ai", () => {
|
||||
const pinnedReport = ref<{ analysis: string } | null>(null)
|
||||
|
||||
async function fetchDetailsData(start: string, end: string) {
|
||||
const res = await getAIDetailData(
|
||||
start,
|
||||
end,
|
||||
targetUsername.value || undefined,
|
||||
)
|
||||
const res = await getAIDetailData(start, end, targetUsername.value || undefined)
|
||||
detailsData.start = res.start
|
||||
detailsData.end = res.end
|
||||
detailsData.grade = res.grade
|
||||
@@ -71,11 +67,7 @@ export const useAIStore = defineStore("ai", () => {
|
||||
}
|
||||
|
||||
async function fetchDurationData(end: string, duration: string) {
|
||||
const res = await getAIDurationData(
|
||||
end,
|
||||
duration,
|
||||
targetUsername.value || undefined,
|
||||
)
|
||||
const res = await getAIDurationData(end, duration, targetUsername.value || undefined)
|
||||
durationData.value = res
|
||||
}
|
||||
|
||||
@@ -105,11 +97,7 @@ export const useAIStore = defineStore("ai", () => {
|
||||
loading.heatmap = false
|
||||
}
|
||||
|
||||
async function fetchAnalysisData(
|
||||
start: string,
|
||||
end: string,
|
||||
duration: string,
|
||||
) {
|
||||
async function fetchAnalysisData(start: string, end: string, duration: string) {
|
||||
rangeStart.value = start
|
||||
rangeEnd.value = end
|
||||
// 换时间范围就回到第一页,否则停在第 5 页但新范围只有两页
|
||||
|
||||
@@ -4,12 +4,7 @@ import { useUserStore } from "shared/store/user"
|
||||
import { ContestStatus, ContestType } from "utils/constants"
|
||||
import { duration } from "utils/functions"
|
||||
import type { Contest, ProblemRow } from "utils/types"
|
||||
import {
|
||||
checkContestPassword,
|
||||
getContest,
|
||||
getContestAccess,
|
||||
getContestProblems,
|
||||
} from "../api"
|
||||
import { checkContestPassword, getContest, getContestAccess, getContestProblems } from "../api"
|
||||
|
||||
export const useContestStore = defineStore("contest", () => {
|
||||
const userStore = useUserStore()
|
||||
@@ -48,13 +43,10 @@ export const useContestStore = defineStore("contest", () => {
|
||||
const isContestAdmin = computed(
|
||||
() =>
|
||||
userStore.isSuperAdmin ||
|
||||
(userStore.isAuthed &&
|
||||
contest.value?.createdBy.id === userStore.user!.id),
|
||||
(userStore.isAuthed && contest.value?.createdBy.id === userStore.user!.id),
|
||||
)
|
||||
|
||||
const isPrivate = computed(
|
||||
() => contest.value!.contestType === ContestType.private,
|
||||
)
|
||||
const isPrivate = computed(() => contest.value!.contestType === ContestType.private)
|
||||
|
||||
async function init(contestID: string) {
|
||||
problems.value = []
|
||||
|
||||
@@ -23,9 +23,7 @@ export const useProblemStore = defineStore("problem", () => {
|
||||
* 题目详情只在 problemID 变化时重新拉(见 oj/problem/detail.vue 的 init),
|
||||
* 而那时下面的 watch 已经把增量清零了,不会和新的 myFailedCount 叠加。
|
||||
*/
|
||||
const failCount = computed(
|
||||
() => (problem.value?.myFailedCount ?? 0) + sessionFailCount.value,
|
||||
)
|
||||
const failCount = computed(() => (problem.value?.myFailedCount ?? 0) + sessionFailCount.value)
|
||||
|
||||
const languages = computed<LANGUAGE[]>(() => {
|
||||
if (route.name === "problem" && problem.value?.allowFlowchart) {
|
||||
|
||||
@@ -42,12 +42,7 @@
|
||||
<!-- 右侧:评分详情区域 -->
|
||||
<n-gi :span="2">
|
||||
<!-- AI反馈 -->
|
||||
<n-card
|
||||
v-if="submission.aiFeedback"
|
||||
size="small"
|
||||
title="AI反馈"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<n-card v-if="submission.aiFeedback" size="small" title="AI反馈" style="margin-bottom: 16px">
|
||||
<n-text>{{ submission.aiFeedback }}</n-text>
|
||||
</n-card>
|
||||
|
||||
@@ -59,10 +54,7 @@
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<n-flex vertical :size="6">
|
||||
<n-text
|
||||
v-for="(suggestion, index) in suggestionLines"
|
||||
:key="`${index}-${suggestion}`"
|
||||
>
|
||||
<n-text v-for="(suggestion, index) in suggestionLines" :key="`${index}-${suggestion}`">
|
||||
{{ suggestion }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
@@ -70,23 +62,11 @@
|
||||
|
||||
<!-- 详细评分 -->
|
||||
<n-card v-if="sortedCriteria.length > 0" size="small" title="详细评分">
|
||||
<div
|
||||
v-for="[key, detail] in sortedCriteria"
|
||||
:key="key"
|
||||
style="margin-bottom: 12px"
|
||||
>
|
||||
<div v-for="[key, detail] in sortedCriteria" :key="key" style="margin-bottom: 12px">
|
||||
<!-- 评分项标题和分数 -->
|
||||
<n-flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style="margin-bottom: 4px"
|
||||
>
|
||||
<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
|
||||
>
|
||||
<n-tag :type="getPercentType(detail.score / detail.max)" size="small" round>
|
||||
{{ detail.score || 0 }}分 / {{ detail.max }}分
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
@@ -121,39 +101,35 @@ const submission = ref<FlowchartSubmission | null>(null)
|
||||
* 评分项明细。契约里是 `Record<string, unknown>` —— 内容是 AI 模型原样吐出的 JSON,
|
||||
* 后端不校验形状,所以这里只能按约定断言,字段缺失时用 0 / 空串兜底。
|
||||
*/
|
||||
const criteriaDetails = computed<
|
||||
Record<string, { score: number; max: number; comment: string }>
|
||||
>(() => {
|
||||
const raw = submission.value?.aiCriteriaDetails ?? {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(raw).map(([key, value]) => {
|
||||
const item = (value ?? {}) as Partial<{
|
||||
score: number
|
||||
max: number
|
||||
comment: string
|
||||
}>
|
||||
return [
|
||||
key,
|
||||
{
|
||||
score: item.score ?? 0,
|
||||
max: item.max ?? 0,
|
||||
comment: item.comment ?? "",
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
})
|
||||
// jsonb 不保留键序,直接遍历会把 40 分的「逻辑正确性」排到最后
|
||||
const sortedCriteria = computed(() =>
|
||||
sortFlowchartCriteria(criteriaDetails.value),
|
||||
const criteriaDetails = computed<Record<string, { score: number; max: number; comment: string }>>(
|
||||
() => {
|
||||
const raw = submission.value?.aiCriteriaDetails ?? {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(raw).map(([key, value]) => {
|
||||
const item = (value ?? {}) as Partial<{
|
||||
score: number
|
||||
max: number
|
||||
comment: string
|
||||
}>
|
||||
return [
|
||||
key,
|
||||
{
|
||||
score: item.score ?? 0,
|
||||
max: item.max ?? 0,
|
||||
comment: item.comment ?? "",
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
// jsonb 不保留键序,直接遍历会把 40 分的「逻辑正确性」排到最后
|
||||
const sortedCriteria = computed(() => sortFlowchartCriteria(criteriaDetails.value))
|
||||
|
||||
const loading = ref(false)
|
||||
const rendering = ref(false)
|
||||
const showLargeImage = ref(false)
|
||||
const suggestionLines = computed(() =>
|
||||
splitSuggestionLines(submission.value?.aiSuggestions),
|
||||
)
|
||||
const suggestionLines = computed(() => splitSuggestionLines(submission.value?.aiSuggestions))
|
||||
|
||||
function splitSuggestionLines(suggestions?: string | null) {
|
||||
return suggestions
|
||||
@@ -184,10 +160,7 @@ async function loadSubmission() {
|
||||
if (submission.value?.mermaidCode) {
|
||||
rendering.value = true
|
||||
await nextTick()
|
||||
await renderFlowchart(
|
||||
mermaidContainer.value,
|
||||
submission.value.mermaidCode,
|
||||
)
|
||||
await renderFlowchart(mermaidContainer.value, submission.value.mermaidCode)
|
||||
rendering.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -42,9 +42,7 @@ const props = defineProps<Props>()
|
||||
defineEmits(["showCode"])
|
||||
|
||||
const userStore = useUserStore()
|
||||
const isOwnSubmission = computed(
|
||||
() => userStore.profile?.user?.id === props.submission.userId,
|
||||
)
|
||||
const isOwnSubmission = computed(() => userStore.profile?.user?.id === props.submission.userId)
|
||||
|
||||
function goto() {
|
||||
window.open("/submission/" + props.submission.id, "_blank")
|
||||
|
||||
@@ -94,9 +94,7 @@ onMounted(async () => {
|
||||
class="hour-bars"
|
||||
:style="{
|
||||
borderBottomColor:
|
||||
hour === currentHour
|
||||
? themeVars.primaryColor
|
||||
: themeVars.dividerColor,
|
||||
hour === currentHour ? themeVars.primaryColor : themeVars.dividerColor,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
@@ -118,9 +116,7 @@ onMounted(async () => {
|
||||
<n-divider style="margin: 16px 0">按语言</n-divider>
|
||||
<div class="rows">
|
||||
<div class="row" v-for="row in stats.languages" :key="row.language">
|
||||
<n-text class="row-name">{{
|
||||
LANGUAGE_SHOW_VALUE[row.language]
|
||||
}}</n-text>
|
||||
<n-text class="row-name">{{ LANGUAGE_SHOW_VALUE[row.language] }}</n-text>
|
||||
<n-progress
|
||||
class="row-bar"
|
||||
type="line"
|
||||
@@ -134,12 +130,7 @@ onMounted(async () => {
|
||||
|
||||
<n-divider style="margin: 16px 0">按状态</n-divider>
|
||||
<n-flex align="center">
|
||||
<n-flex
|
||||
align="center"
|
||||
:size="4"
|
||||
v-for="row in stats.results"
|
||||
:key="row.result"
|
||||
>
|
||||
<n-flex align="center" :size="4" v-for="row in stats.results" :key="row.result">
|
||||
<SubmissionResultTag :result="row.result" />
|
||||
<n-text>{{ row.count }}</n-text>
|
||||
</n-flex>
|
||||
@@ -148,11 +139,7 @@ onMounted(async () => {
|
||||
<template v-if="stats.problems.length">
|
||||
<n-divider style="margin: 16px 0">今天最热的题</n-divider>
|
||||
<div class="rows">
|
||||
<div
|
||||
class="row"
|
||||
v-for="row in stats.problems"
|
||||
:key="row.problemDisplayId"
|
||||
>
|
||||
<div class="row" v-for="row in stats.problems" :key="row.problemDisplayId">
|
||||
<n-button
|
||||
class="problem"
|
||||
text
|
||||
@@ -169,11 +156,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
</template>
|
||||
<!-- 请求失败时 stats 还是 null,转圈停下来总得留句话 -->
|
||||
<n-empty
|
||||
v-else-if="!loading"
|
||||
description="统计拉取失败"
|
||||
style="margin: 40px 0"
|
||||
/>
|
||||
<n-empty v-else-if="!loading" description="统计拉取失败" style="margin: 40px 0" />
|
||||
<div v-else style="height: 200px"></div>
|
||||
</n-spin>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { getSubmission } from "oj/api"
|
||||
import type { JudgeCaseResult } from "@oj2/contract"
|
||||
import {
|
||||
JUDGE_STATUS,
|
||||
LANGUAGE_FORMAT_VALUE,
|
||||
LANGUAGE_SHOW_VALUE,
|
||||
} from "utils/constants"
|
||||
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE, LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import {
|
||||
parseTime,
|
||||
submissionCaseResults,
|
||||
@@ -43,9 +39,7 @@ const loading = ref(false)
|
||||
* 测试点明细。`info` 在契约里是「完整形状或空对象」的联合(非管理员拿到的是空对象),
|
||||
* `data` 本身也可能为 null —— 两种情况都由这个访问器归成空数组,模板里不再直接取。
|
||||
*/
|
||||
const caseResults = computed(() =>
|
||||
submissionCaseResults(submission.value?.info),
|
||||
)
|
||||
const caseResults = computed(() => submissionCaseResults(submission.value?.info))
|
||||
|
||||
async function init() {
|
||||
submission.value = props.submission
|
||||
@@ -141,11 +135,7 @@ onMounted(init)
|
||||
</n-flex>
|
||||
</n-alert>
|
||||
<n-flex :vertical="isDesktop" justify="center">
|
||||
<n-button
|
||||
v-if="submission.language !== 'SQL'"
|
||||
secondary
|
||||
@click="copyToCat"
|
||||
>
|
||||
<n-button v-if="submission.language !== 'SQL'" secondary @click="copyToCat">
|
||||
复制到自测猫
|
||||
</n-button>
|
||||
<n-button secondary @click="copyToProblem">复制回到题目</n-button>
|
||||
@@ -159,11 +149,7 @@ onMounted(init)
|
||||
show-line-numbers
|
||||
/>
|
||||
</n-card>
|
||||
<n-data-table
|
||||
v-if="!hideList && caseResults.length"
|
||||
:columns="columns"
|
||||
:data="caseResults"
|
||||
/>
|
||||
<n-data-table v-if="!hideList && caseResults.length" :columns="columns" :data="caseResults" />
|
||||
</n-flex>
|
||||
<n-spin v-else :show="loading" class="loading-container"> </n-spin>
|
||||
</template>
|
||||
|
||||
@@ -11,11 +11,7 @@ import {
|
||||
} from "oj/api"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Grade as GradeValue } from "utils/types"
|
||||
import type {
|
||||
FlowchartSubmissionListItem,
|
||||
LANGUAGE,
|
||||
SubmissionListItem,
|
||||
} from "utils/types"
|
||||
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"
|
||||
@@ -44,15 +40,11 @@ import FlowchartLink from "./components/FlowchartLink.vue"
|
||||
// 下面四个组件只在默认关闭的 n-modal 里用,其中两个统计面板还只有老师看得见。
|
||||
// 静态 import 会把它们拖进本路由的关键路径——光 chart.js 就 197KB,进页面前必须先下完。
|
||||
// 改成异步后本路由增量下载从 675KB / 59 个文件降到 360KB 出头。
|
||||
const StatisticsPanel = defineAsyncComponent(
|
||||
() => import("shared/components/StatisticsPanel.vue"),
|
||||
)
|
||||
const StatisticsPanel = defineAsyncComponent(() => import("shared/components/StatisticsPanel.vue"))
|
||||
const FlowchartStatisticsPanel = defineAsyncComponent(
|
||||
() => import("shared/components/FlowchartStatisticsPanel.vue"),
|
||||
)
|
||||
const TodayStatistics = defineAsyncComponent(
|
||||
() => import("./components/TodayStatistics.vue"),
|
||||
)
|
||||
const TodayStatistics = defineAsyncComponent(() => import("./components/TodayStatistics.vue"))
|
||||
const SubmissionDetail = defineAsyncComponent(() => import("./detail.vue"))
|
||||
const FlowchartScoreDetail = defineAsyncComponent(
|
||||
() => import("./components/FlowchartScoreDetail.vue"),
|
||||
@@ -267,9 +259,7 @@ function showCodePanel(id: string, problem: string) {
|
||||
* 只在**能看代码**的行之间走 —— showLink 是后端按题单规则算出来的,
|
||||
* 跳到一条看不了的上面只会得到一个空弹框。
|
||||
*/
|
||||
const viewableSubmissions = computed(() =>
|
||||
submissions.value.filter((row) => row.showLink),
|
||||
)
|
||||
const viewableSubmissions = computed(() => submissions.value.filter((row) => row.showLink))
|
||||
const currentCodeIndex = computed(() =>
|
||||
viewableSubmissions.value.findIndex((row) => row.id === submissionID.value),
|
||||
)
|
||||
@@ -308,24 +298,19 @@ function moveCodePanel(step: 1 | -1) {
|
||||
|
||||
// 上下(和左右)翻阅代码详情。Esc 关弹框是 n-modal 自带的,不用管。
|
||||
// 弹框没开就什么都不做,页面正常滚动
|
||||
onKeyStroke(
|
||||
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"],
|
||||
(e: KeyboardEvent) => {
|
||||
if (!codePanel.value) return
|
||||
// 焦点在输入框里(弹框底下那几个筛选框)时不抢方向键
|
||||
const target = e.target as HTMLElement | null
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
moveCodePanel(e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : 1)
|
||||
},
|
||||
)
|
||||
onKeyStroke(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"], (e: KeyboardEvent) => {
|
||||
if (!codePanel.value) return
|
||||
// 焦点在输入框里(弹框底下那几个筛选框)时不抢方向键
|
||||
const target = e.target as HTMLElement | null
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)
|
||||
) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
moveCodePanel(e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : 1)
|
||||
})
|
||||
|
||||
function showScoreDetail(id: string) {
|
||||
selectedFlowchartId.value = id
|
||||
@@ -348,14 +333,7 @@ watchDebounced(() => [query.username, query.problem], listSubmissions, {
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
() => [
|
||||
query.page,
|
||||
query.limit,
|
||||
query.myself,
|
||||
query.result,
|
||||
query.language,
|
||||
query.today,
|
||||
],
|
||||
() => [query.page, query.limit, query.myself, query.result, query.language, query.today],
|
||||
listSubmissions,
|
||||
)
|
||||
|
||||
@@ -427,8 +405,7 @@ const columns = computed(() => {
|
||||
type: "info",
|
||||
bordered: false,
|
||||
style: { cursor: "pointer", flexShrink: 0 },
|
||||
onClick: () =>
|
||||
window.open("/problemset/" + problemSet.id, "_blank"),
|
||||
onClick: () => window.open("/problemset/" + problemSet.id, "_blank"),
|
||||
},
|
||||
() => "题单 " + problemSet.title,
|
||||
),
|
||||
@@ -442,10 +419,7 @@ const columns = computed(() => {
|
||||
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"用户",
|
||||
"streamline-emojis:smiling-face-with-sunglasses",
|
||||
),
|
||||
title: renderTableTitle("用户", "streamline-emojis:smiling-face-with-sunglasses"),
|
||||
key: "username",
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
@@ -525,10 +499,7 @@ const flowchartColumns = computed(() => {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"评分",
|
||||
"streamline-ultimate-color:analytics-bars-3d",
|
||||
),
|
||||
title: renderTableTitle("评分", "streamline-ultimate-color:analytics-bars-3d"),
|
||||
key: "ai_score",
|
||||
// 只有评完的才有分数。没评完也渲染 Grade 的话会显示成 0 分,
|
||||
// 看着像「评了但得了 0 分」
|
||||
@@ -541,10 +512,7 @@ const flowchartColumns = computed(() => {
|
||||
: h(NText, { depth: 3 }, () => "—"),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"用户",
|
||||
"streamline-emojis:smiling-face-with-sunglasses",
|
||||
),
|
||||
title: renderTableTitle("用户", "streamline-emojis:smiling-face-with-sunglasses"),
|
||||
key: "username",
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
@@ -591,26 +559,16 @@ const flowchartColumns = computed(() => {
|
||||
<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-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-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
|
||||
"
|
||||
:options="query.language === 'Flowchart' ? gradeOptions : resultOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
@@ -635,28 +593,16 @@ const flowchartColumns = computed(() => {
|
||||
</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-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-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-form-item v-if="userStore.isTeacherOrAbove && route.name === 'submissions'">
|
||||
<n-button quaternary type="warning" @click="toggleStatisticPanel(true)">
|
||||
数据统计
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
@@ -704,31 +650,21 @@ const flowchartColumns = computed(() => {
|
||||
:loading="showLoading"
|
||||
/>
|
||||
</n-flex>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
<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' ? '流程图提交的统计' : '提交记录的统计'
|
||||
"
|
||||
: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"
|
||||
/>
|
||||
<StatisticsPanel v-else :problem="query.problem" :username="query.username" />
|
||||
</n-modal>
|
||||
<n-modal
|
||||
v-model:show="todayPanel"
|
||||
@@ -777,10 +713,7 @@ const flowchartColumns = computed(() => {
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<n-text>流程图评分详情</n-text>
|
||||
<n-text
|
||||
v-if="selectedFlowchart"
|
||||
:type="getGradeType(selectedFlowchart.aiGrade ?? '')"
|
||||
>
|
||||
<n-text v-if="selectedFlowchart" :type="getGradeType(selectedFlowchart.aiGrade ?? '')">
|
||||
{{ selectedFlowchart.aiScore }}分 {{ selectedFlowchart.aiGrade }}级
|
||||
</n-text>
|
||||
</n-flex>
|
||||
|
||||
@@ -25,9 +25,7 @@ const [show, toggleShow] = useToggle(false)
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const isDefaultAvatar = computed(
|
||||
() => profile.value?.avatar.endsWith("default.png") ?? true,
|
||||
)
|
||||
const isDefaultAvatar = computed(() => profile.value?.avatar.endsWith("default.png") ?? true)
|
||||
|
||||
const problemsFlexRef = useTemplateRef<HTMLElement>("problemsFlexRef")
|
||||
const itemsPerRow = ref(8)
|
||||
@@ -55,9 +53,7 @@ const visibleProblems = computed(() =>
|
||||
show.value ? problems.value : problems.value.slice(0, itemsPerRow.value * 3),
|
||||
)
|
||||
|
||||
const hasMoreProblems = computed(
|
||||
() => problems.value.length > itemsPerRow.value * 3,
|
||||
)
|
||||
const hasMoreProblems = computed(() => problems.value.length > itemsPerRow.value * 3)
|
||||
|
||||
async function init() {
|
||||
toggle(true)
|
||||
@@ -92,9 +88,7 @@ async function init() {
|
||||
// 插一项进去会打乱既有索引。成就摘要取不到也不该影响整个个人主页
|
||||
async function loadAchievementSummary() {
|
||||
try {
|
||||
const res = await getAchievementSummary(
|
||||
(route.query.name as string) || undefined,
|
||||
)
|
||||
const res = await getAchievementSummary((route.query.name as string) || undefined)
|
||||
achievementSummary.value = res
|
||||
} catch {
|
||||
achievementSummary.value = null
|
||||
@@ -145,13 +139,7 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<n-flex
|
||||
class="wrapper"
|
||||
vertical
|
||||
justify="center"
|
||||
align="center"
|
||||
v-if="!loading && profile"
|
||||
>
|
||||
<n-flex class="wrapper" vertical justify="center" align="center" v-if="!loading && profile">
|
||||
<n-image
|
||||
:width="140"
|
||||
:height="140"
|
||||
@@ -207,20 +195,14 @@ onMounted(() => {
|
||||
</n-grid>
|
||||
|
||||
<!-- 成就摘要 -->
|
||||
<n-card
|
||||
v-if="!loading && profile && achievementSummary"
|
||||
class="wrapper"
|
||||
hoverable
|
||||
>
|
||||
<n-card v-if="!loading && profile && achievementSummary" class="wrapper" hoverable>
|
||||
<n-flex align="center" justify="space-between">
|
||||
<n-flex align="center" :size="12">
|
||||
<span class="achievement-title">
|
||||
成就 {{ achievementSummary.unlocked }} /
|
||||
{{ achievementSummary.total }}
|
||||
</span>
|
||||
<n-tag size="small" type="info">
|
||||
{{ achievementSummary.percent }}%
|
||||
</n-tag>
|
||||
<n-tag size="small" type="info"> {{ achievementSummary.percent }}% </n-tag>
|
||||
</n-flex>
|
||||
<n-button
|
||||
text
|
||||
@@ -236,9 +218,7 @@ onMounted(() => {
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex align="center" :size="10" class="achievement-recent">
|
||||
<n-text v-if="achievementSummary.recent.length" depth="3">
|
||||
最近获得
|
||||
</n-text>
|
||||
<n-text v-if="achievementSummary.recent.length" depth="3"> 最近获得 </n-text>
|
||||
<n-tooltip v-for="a in achievementSummary.recent" :key="a.id">
|
||||
<template #trigger>
|
||||
<span class="achievement-icon">
|
||||
@@ -247,9 +227,7 @@ onMounted(() => {
|
||||
</template>
|
||||
{{ a.name }}
|
||||
</n-tooltip>
|
||||
<n-text v-if="!achievementSummary.recent.length" depth="3">
|
||||
还没有获得成就
|
||||
</n-text>
|
||||
<n-text v-if="!achievementSummary.recent.length" depth="3"> 还没有获得成就 </n-text>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
|
||||
@@ -258,23 +236,14 @@ onMounted(() => {
|
||||
<template #label>
|
||||
<n-flex justify="space-between" align="center">
|
||||
<span>已解决的题目</span>
|
||||
<n-button
|
||||
text
|
||||
type="primary"
|
||||
v-if="hasMoreProblems"
|
||||
@click="toggleShow(!show)"
|
||||
>
|
||||
<n-button text type="primary" v-if="hasMoreProblems" @click="toggleShow(!show)">
|
||||
{{ show ? "隐藏全部" : "显示全部" }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<div ref="problemsFlexRef">
|
||||
<n-flex>
|
||||
<n-button
|
||||
v-for="id in visibleProblems"
|
||||
:key="id"
|
||||
@click="router.push('/problem/' + id)"
|
||||
>
|
||||
<n-button v-for="id in visibleProblems" :key="id" @click="router.push('/problem/' + id)">
|
||||
{{ id }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<template>
|
||||
<n-list v-if="messages.length">
|
||||
<n-list-item
|
||||
:style="{ overflow: 'auto' }"
|
||||
v-for="(item, index) in messages"
|
||||
:key="index"
|
||||
>
|
||||
<n-list-item :style="{ overflow: 'auto' }" v-for="(item, index) in messages" :key="index">
|
||||
<n-flex size="large" vertical>
|
||||
<n-flex align="center">
|
||||
<div>发送时间</div>
|
||||
@@ -36,11 +32,7 @@
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
<n-empty v-else description="没有消息"></n-empty>
|
||||
<Pagination
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
:total="total"
|
||||
/>
|
||||
<Pagination v-model:limit="query.limit" v-model:page="query.page" :total="total" />
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { getMessageList } from "oj/api"
|
||||
|
||||
@@ -5,10 +5,7 @@ import { useUserStore } from "shared/store/user"
|
||||
const userStore = useUserStore()
|
||||
const message = useMessage()
|
||||
|
||||
async function beforeUpload(data: {
|
||||
file: UploadFileInfo
|
||||
fileList: UploadFileInfo[]
|
||||
}) {
|
||||
async function beforeUpload(data: { file: UploadFileInfo; fileList: UploadFileInfo[] }) {
|
||||
if (!data.file.file) return false
|
||||
if (data.file.file.size > 2 * 1024 * 1024) {
|
||||
message.warning("图片太大啦!不能超过 2 MB 啊")
|
||||
|
||||
Reference in new issue
Block a user