update
Deploy / deploy (push) Canceled after 0s

This commit is contained in:
xuyue committed 2026-09-27 19:36:28 +08:00
1 parent 23844ae301
commit d25b5dbe6d
268 files changed
+3684 -8861

No files matched your search

+1 -5
View File
@@ -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>
+10 -46
View File
@@ -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"
+2 -8
View File
@@ -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),
),
)
+2 -5
View File
@@ -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()
+2 -9
View File
@@ -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日")} 期间`
+3 -14
View File
@@ -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, "基于同时段排名的百分位:"),
+1 -7
View File
@@ -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,
+2 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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[]
+4 -18
View File
@@ -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>
+4 -21
View File
@@ -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>
+8 -33
View File
@@ -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
+14 -60
View File
@@ -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
+16 -67
View File
@@ -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">
+9 -40
View File
@@ -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>
+3 -8
View File
@@ -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>
+2 -11
View File
@@ -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"
+13 -52
View File
@@ -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>
+1 -9
View File
@@ -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
View File
@@ -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"
+3 -15
View File
@@ -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 页但新范围只有两页
+3 -11
View File
@@ -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 = []
+1 -3
View File
@@ -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>
+4 -18
View File
@@ -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>
+33 -100
View File
@@ -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>
+10 -41
View File
@@ -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>
+2 -10
View File
@@ -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"
+1 -4
View File
@@ -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 啊")