feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码

This commit is contained in:
2026-08-06 21:18:16 -06:00
parent 3c975e85ee
commit ae1fb329b5
258 changed files with 40490 additions and 91 deletions

View File

@@ -0,0 +1,27 @@
import http from "utils/http"
import type {
Achievement,
AchievementSummary,
PendingAchievement,
} from "utils/types"
export function getAchievements(name?: string) {
return http.get<{ username: string; achievements: Achievement[] }>(
"achievements",
{ params: name ? { name } : {} },
)
}
export function getAchievementSummary(name?: string) {
return http.get<AchievementSummary>("achievements/summary", {
params: name ? { name } : {},
})
}
export function getPendingAchievements() {
return http.get<PendingAchievement[]>("achievements/pending")
}
export function markAchievementsRead(ids: number[]) {
return http.post("achievements/pending", { ids })
}

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useRarityColor } from "shared/composables/rarity"
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
import type { Achievement } from "utils/types"
const props = defineProps<{ achievement: Achievement }>()
// 边框用原色tag 里的文字用跟主题走的那套
const rarityTextColor = useRarityColor()
// 隐藏且未解锁:后端已把名称/描述/图标和条件三件套都遮成 ??? 和 null
// 这里只负责不要把 null 渲染出来,也不要画出会泄露门槛的进度条
const masked = computed(
() => props.achievement.hidden && !props.achievement.unlocked,
)
// 获得率低于 5% 的加稀有闪光边框
const isRare = computed(
() => props.achievement.unlock_rate > 0 && props.achievement.unlock_rate < 5,
)
// 只有"越多越好"的成就画进度条。lte 类(如最短 AC 代码 ≤ 50 字符)
// 画成百分比毫无意义,改成直接显示当前最好成绩
const showProgressBar = computed(
() =>
!masked.value &&
!props.achievement.unlocked &&
props.achievement.operator === "gte" &&
props.achievement.threshold !== null,
)
const showBestSoFar = computed(
() =>
!masked.value &&
!props.achievement.unlocked &&
props.achievement.operator === "lte" &&
props.achievement.threshold !== null,
)
const percent = computed(() => {
const { progress, threshold } = props.achievement
if (threshold === null || threshold <= 0) return 100
return Math.min(100, Math.round(((progress ?? 0) / threshold) * 100))
})
const unlockDate = computed(() => {
const { unlock_time, backfilled } = props.achievement
// 补发的记录不显示具体日期:一次补发会给几百人盖上同一个时间戳
if (backfilled || !unlock_time) return "已获得"
return `${new Date(unlock_time).toLocaleDateString()} 获得`
})
</script>
<template>
<n-card
size="small"
:class="{ locked: !achievement.unlocked, rare: isRare }"
:style="{ borderColor: RARITY_COLOR[achievement.rarity] }"
>
<n-thing>
<template #avatar>
<AchievementIcon :icon="achievement.icon" :size="32" />
</template>
<template #header>
<n-flex align="center" :size="8">
<n-text strong>{{ achievement.name }}</n-text>
<n-tag
size="tiny"
:color="{
borderColor: RARITY_COLOR[achievement.rarity],
textColor: rarityTextColor[achievement.rarity],
}"
>
{{ RARITY_LABEL[achievement.rarity] }}
</n-tag>
</n-flex>
</template>
<template #description>
<n-text depth="3">{{ achievement.description }}</n-text>
</template>
<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.unlock_rate }}% 的人获得
</n-text>
</template>
<template v-else-if="showProgressBar">
<n-progress
style="flex: 1"
type="line"
:percentage="percent"
:height="6"
:show-indicator="false"
/>
<n-text depth="3" class="nowrap">
{{ achievement.progress ?? 0 }} / {{ achievement.threshold }}
</n-text>
</template>
<template v-else-if="showBestSoFar">
<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.unlock_rate }}% 的人获得
</n-text>
</n-flex>
</n-thing>
</n-card>
</template>
<style scoped>
.nowrap {
white-space: nowrap;
}
.locked {
filter: grayscale(1);
opacity: 0.55;
}
.rare {
box-shadow: 0 0 12px rgba(125, 211, 252, 0.55);
}
</style>

View File

@@ -0,0 +1,227 @@
<script setup lang="ts">
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,
} from "utils/types"
import AchievementCard from "./components/AchievementCard.vue"
interface UserBadge {
id: number
earned_time: string
badge: {
id: number
name: string
description: string
icon: string
}
// 奖章来自哪个题单,接口在 UserBadgeSerializer 里带出来
problemset: {
id: number
title: string
} | null
}
const route = useRoute()
const name = computed(() => (route.query.name as string) || undefined)
// 标签和进度条同色,整行读作一个单位
const rarityColor = useRarityColor()
const achievements = ref<Achievement[]>([])
const summary = ref<AchievementSummary | null>(null)
// 白金排最前,青铜垫底:稀有的先亮相,接口给的顺序是反的
const RARITY_RANK: Record<AchievementRarity, number> = {
platinum: 0,
gold: 1,
silver: 2,
bronze: 3,
}
const rarities = computed(() =>
[...(summary.value?.rarity ?? [])].sort(
(a, b) => RARITY_RANK[a.rarity] - RARITY_RANK[b.rarity],
),
)
const badges = ref<UserBadge[]>([])
const { isDesktop } = useBreakpoints()
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)
return achievements.value
})
async function load() {
loading.value = true
try {
const [list, sum, badgeRes] = await Promise.all([
getAchievements(name.value),
getAchievementSummary(name.value),
getUserBadges(name.value),
])
// http 客户端返回 ApiResponse<T>,真实载荷在 .data 里
achievements.value = list.data.achievements
summary.value = sum.data
badges.value = (badgeRes.data ?? []) as UserBadge[]
} finally {
loading.value = false
}
}
onMounted(load)
watch(name, load)
</script>
<template>
<div class="hall">
<!-- delay 50ms缓存命中时数据几乎立刻回来不闪一下转圈 -->
<n-spin :show="loading" :delay="50" style="min-height: 240px">
<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-text strong :style="{ fontSize: isDesktop ? '20px' : '14px' }">
{{ summary.percent }}%
</n-text>
</n-progress>
<n-text depth="3" class="nowrap">
已获得 {{ summary.unlocked }} / {{ summary.total }}
</n-text>
</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-text strong :style="{ color: rarityColor[r.rarity] }">
{{ r.label }}
</n-text>
<n-progress
style="flex: 1"
type="line"
:percentage="r.total ? (r.unlocked / r.total) * 100 : 0"
:height="6"
:border-radius="3"
:fill-border-radius="3"
:color="rarityColor[r.rarity]"
:show-indicator="false"
/>
<n-text depth="3" class="nowrap">
{{ r.unlocked }} / {{ r.total }}
</n-text>
</n-flex>
</n-flex>
</n-flex>
</n-card>
<n-tabs v-model:value="tab" type="line" class="tabs">
<n-tab name="all">全部</n-tab>
<n-tab name="unlocked">已获得</n-tab>
<n-tab name="locked">未获得</n-tab>
<n-tab name="badges">题单奖章</n-tab>
</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-gi v-for="a in filtered" :key="a.id">
<AchievementCard :achievement="a" />
</n-gi>
</n-grid>
<!-- 加载中不显示空态不然首屏会闪一下"什么都没有" -->
<n-empty v-else-if="!loading" description="这里还什么都没有" />
</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-gi v-for="b in badges" :key="b.id">
<n-card size="small">
<n-thing
:title="b.badge?.name"
:description="b.badge?.description"
>
<template #avatar v-if="b.badge?.icon">
<n-avatar
:size="40"
:src="b.badge.icon"
color="transparent"
object-fit="contain"
/>
</template>
<n-text v-if="b.problemset" depth="3" class="source">
来自题单
<router-link
:to="{
name: 'problemset',
params: { problemSetId: b.problemset.id },
}"
>
{{ b.problemset.title }}
</router-link>
</n-text>
</n-thing>
</n-card>
</n-gi>
</n-grid>
<n-empty v-else-if="!loading" description="还没有获得任何题单奖章" />
</template>
</n-spin>
</div>
</template>
<style scoped>
.hall {
max-width: 1100px;
margin: 0 auto;
padding: 16px;
}
.rarity {
flex: 1;
max-width: 420px;
}
.nowrap {
white-space: nowrap;
}
.tabs {
margin: 16px 0;
}
.source {
display: block;
margin-top: 6px;
font-size: 13px;
}
.source a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<n-spin :show="aiStore.loading.fetching" :delay="50">
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
<n-gi :span="1">
<n-flex vertical size="large">
<n-flex align="center" justify="space-between">
<n-h3 style="margin: 0">请选择时间范围智能分析学习情况</n-h3>
<n-flex align="center">
<n-input
v-if="userStore.isSuperAdmin"
v-model:value="urlUsername"
placeholder="查看指定用户"
clearable
style="width: 140px"
@change="onUsernameChange"
@clear="onUsernameChange"
/>
<n-select
style="width: 140px"
:options="options"
v-model:value="urlDuration"
/>
</n-flex>
</n-flex>
<Overview />
<n-grid :cols="2" :x-gap="20" :y-gap="20">
<n-gi :span="isDesktop ? 1 : 2">
<DifficultyGradeChart />
</n-gi>
<n-gi :span="isDesktop ? 1 : 2">
<TagsRadarChart />
</n-gi>
<n-gi :span="isDesktop ? 1 : 2">
<RankDistributionChart />
</n-gi>
<n-gi :span="isDesktop ? 1 : 2">
<TimeActivityHeatmap />
</n-gi>
</n-grid>
<SolvedTable />
</n-flex>
</n-gi>
<n-gi :span="1">
<n-flex vertical size="large">
<Heatmap />
<ProgressChart />
<EfficiencyChart />
<DurationChart />
<AI v-if="aiStore.detailsData.solved.length > 10" />
</n-flex>
</n-gi>
<n-gi :span="2">
<AI
v-if="
aiStore.detailsData.solved.length > 0 &&
aiStore.detailsData.solved.length <= 10
"
/>
</n-gi>
</n-grid>
</n-spin>
</template>
<script setup lang="ts">
import { useBreakpoints } from "shared/composables/breakpoints"
import { formatISO, sub, type Duration } from "date-fns"
import { useRouteQuery } from "@vueuse/router"
import TagsRadarChart from "./components/TagsRadarChart.vue"
import DifficultyGradeChart from "./components/DifficultyGradeChart.vue"
import TimeActivityHeatmap from "./components/TimeActivityHeatmap.vue"
import RankDistributionChart from "./components/RankDistributionChart.vue"
import Overview from "./components/Overview.vue"
import Heatmap from "./components/Heatmap.vue"
import ProgressChart from "./components/ProgressChart.vue"
import DurationChart from "./components/DurationChart.vue"
import EfficiencyChart from "./components/EfficiencyChart.vue"
import AI from "./components/AI.vue"
import SolvedTable from "./components/SolvedTable.vue"
import { useAIStore } from "../store/ai"
import { useUserStore } from "shared/store/user"
import { DURATION_OPTIONS } from "utils/constants"
const aiStore = useAIStore()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const options = [...DURATION_OPTIONS]
const urlUsername = useRouteQuery<string>("username", "")
const urlDuration = useRouteQuery<string>("duration", "months:6")
// Initialize store synchronously from URL params before watch fires
aiStore.targetUsername = urlUsername.value
aiStore.duration = urlDuration.value
const subOptions = computed<Duration>(() => {
let dur = options.find((it) => it.value === aiStore.duration) ?? options[0]
const x = dur.value!.toString().split(":")
return { [x[0]]: parseInt(x[1]) } as Duration
})
const start = computed(() => formatISO(sub(new Date(), subOptions.value)))
const end = computed(() => formatISO(new Date()))
function onUsernameChange() {
aiStore.targetUsername = urlUsername.value
aiStore.fetchHeatmapData()
aiStore.fetchAnalysisData(start.value, end.value, aiStore.duration)
}
onMounted(() => {
aiStore.fetchHeatmapData()
})
watch(
() => urlDuration.value,
(val) => {
aiStore.duration = val
aiStore.fetchAnalysisData(start.value, end.value, val)
},
{ immediate: true },
)
</script>

View File

@@ -0,0 +1,91 @@
<template>
<n-card size="small">
<template #header>
<div class="cool-title">
<span class="title-text">AI 帮你分析</span>
</div>
</template>
<n-spin :show="aiStore.loading.ai" :delay="50">
<n-flex align="center" justify="center" class="container">
<n-button
v-if="!aiStore.mdContent && !aiStore.loading.ai"
type="primary"
size="large"
:loading="aiStore.loading.fetching"
@click="handleAnalyze"
>
<template #icon>
<Icon icon="ph:sparkle" />
</template>
开始分析
</n-button>
<MdPreview v-else :model-value="aiStore.mdContent" />
</n-flex>
</n-spin>
</n-card>
</template>
<script setup lang="ts">
import { useAIStore } from "oj/store/ai"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { Icon } from "@iconify/vue"
const aiStore = useAIStore()
async function handleAnalyze() {
if (aiStore.loading.fetching || aiStore.loading.ai) {
return
}
if (aiStore.pinnedReport) {
await aiStore.simulatePinnedStream()
} else {
await aiStore.fetchAIAnalysis()
}
}
onMounted(async () => {
if (!aiStore.targetUsername) {
await aiStore.fetchPinnedReport()
}
})
</script>
<style scoped>
.cool-title {
position: relative;
padding: 8px 0;
}
.title-text {
font-size: 16px;
font-weight: 700;
background: linear-gradient(45deg, #667eea, #764ba2, #f093fb);
background-size: 200% 200%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 0.8px;
position: relative;
z-index: 2;
animation: gradient-flow 3s ease infinite;
}
@keyframes gradient-flow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.container {
min-height: 200px;
}
:deep(.md-editor-preview h1) {
margin-top: 0;
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<n-card title="难度掌握情况" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">
了解不同难度题目的完成等级分布
</n-text>
</template>
<div style="height: 300px">
<Bar :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Bar } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
import type { Grade } from "utils/types"
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const aiStore = useAIStore()
// 难度和等级的顺序(后端返回的是中文)
const difficultyOrder = ["简单", "中等", "困难"]
const gradeOrder: Grade[] = ["S", "A", "B", "C"]
// 统计每个难度-等级组合的题目数量
const matrix = computed(() => {
const result: { [difficulty: string]: { [grade: string]: number } } = {}
// 初始化矩阵
difficultyOrder.forEach((diff) => {
result[diff] = {}
gradeOrder.forEach((grade) => {
result[diff][grade] = 0
})
})
// 统计数据
aiStore.detailsData.solved.forEach((item) => {
const diff = item.difficulty
const grade = item.grade
if (diff && grade && result[diff]) {
result[diff][grade]++
}
})
return result
})
const show = computed(() => {
return aiStore.detailsData.solved.length > 0
})
// 为每个等级准备数据集
const data = computed(() => {
// 为每个等级生成一个 dataset
const datasets = gradeOrder.map((grade) => {
return {
label: `等级 ${grade}`,
data: difficultyOrder.map((diff) => matrix.value[diff][grade]),
backgroundColor: getGradeColor(grade),
borderColor: getGradeColor(grade),
borderWidth: 1,
}
})
return {
labels: difficultyOrder,
datasets,
}
})
// 根据等级返回对应的颜色
function getGradeColor(grade: Grade): string {
const colors: { [key in Grade]: string } = {
S: "#FF6384",
A: "#FFCE56",
B: "#36A2EB",
C: "#95F204",
}
return colors[grade]
}
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index" as const,
},
scales: {
x: {
stacked: true,
grid: {
display: false,
},
},
y: {
stacked: true,
ticks: {
stepSize: 1,
},
title: {
display: true,
text: "题目数量",
},
},
},
plugins: {
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
padding: 8,
font: {
size: 11,
},
},
},
title: {
display: false,
},
tooltip: {
callbacks: {
footer: (items: any[]) => {
const total = items.reduce((sum, item) => sum + item.parsed.y, 0)
return `该难度总计: ${total}`
},
},
},
},
}
</script>

View File

@@ -0,0 +1,204 @@
<template>
<n-card :title="title" size="small">
<template #header-extra>
<n-text depth="3" style="font-size: 12px"> 全面评估学习情况 </n-text>
</template>
<div class="chart">
<Chart type="bar" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
import { Chart } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Title,
Tooltip,
Legend,
Colors,
LineController,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions"
// 注册混合图表Bar + Line所需的 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Title,
Tooltip,
Legend,
Colors,
LineController,
)
const aiStore = useAIStore()
const gradeOrder = ["C", "B", "A", "S"] as const
const title = computed(() => {
if (aiStore.duration === "months:2") {
return "过去两个月的每周综合情况"
} else if (aiStore.duration === "months:6") {
return "过去半年的每月综合情况"
} else if (aiStore.duration === "years:1") {
return "过去一年的每月综合情况"
} else {
return "过去四周的综合情况"
}
})
const data = computed<ChartData<"bar" | "line">>(() => {
return {
labels: aiStore.durationData.map((duration) => {
let prefix = "周"
if (duration.unit === "months") {
prefix = "月"
}
return [
parseTime(duration.start, "M月D日"),
parseTime(duration.end, "M月D日"),
].join("")
}),
datasets: [
{
type: "bar",
label: "完成题目数",
data: aiStore.durationData.map((duration) => duration.problem_count),
yAxisID: "y",
order: 2,
},
{
type: "bar",
label: "总提交次数",
data: aiStore.durationData.map((duration) => duration.submission_count),
yAxisID: "y",
order: 2,
},
{
type: "line",
label: "等级",
data: aiStore.durationData.map((duration) =>
duration.grade ? gradeOrder.indexOf(duration.grade) : null,
),
spanGaps: false,
tension: 0.4,
yAxisID: "y1",
barThickness: 10,
order: 1,
borderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
},
],
}
})
const options = computed<ChartOptions<"bar" | "line">>(() => {
return {
interaction: {
intersect: false,
},
maintainAspectRatio: false,
scales: {
x: {
grid: {
display: false,
},
},
y: {
ticks: {
stepSize: 1,
},
title: {
display: true,
text: "数量",
},
beginAtZero: true,
},
y1: {
type: "linear",
position: "right",
min: -0.5,
max: gradeOrder.length - 0.5,
ticks: {
stepSize: 1,
callback: (v) => {
const idx = Number(v)
return gradeOrder[idx] || ""
},
},
title: {
display: true,
text: "等级",
},
grid: {
display: false,
},
},
},
plugins: {
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
padding: 8,
font: {
size: 11,
},
},
},
title: {
display: false,
},
tooltip: {
callbacks: {
label: (ctx: TooltipItem<"bar" | "line">) => {
const dsLabel = ctx.dataset.label || ""
if ((ctx.dataset as any).yAxisID === "y1") {
const idx = Number(ctx.parsed.y)
return `${dsLabel}: ${gradeOrder[idx] || ""}`
}
return `${dsLabel}: ${ctx.formattedValue}`
},
footer: (items: TooltipItem<"bar" | "line">[]) => {
const barItems = items.filter(
(item) => (item.dataset as any).yAxisID === "y",
)
if (barItems.length >= 2) {
const problemCount =
barItems.find((item) => item.dataset.label === "完成题目数")
?.parsed.y || 0
const submissionCount =
barItems.find((item) => item.dataset.label === "总提交次数")
?.parsed.y || 0
const efficiency =
submissionCount > 0
? ((problemCount / submissionCount) * 100).toFixed(1)
: "0"
return `AC率: ${efficiency}%`
}
return ""
},
},
},
},
}
})
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,234 @@
<template>
<n-card :title="title" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">反映刷题质量提升</n-text>
</template>
<div class="chart">
<Chart type="line" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
import { Chart } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions"
// 注册折线图所需的 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
)
const aiStore = useAIStore()
const title = computed(() => {
if (aiStore.duration === "months:2") {
return "过去两个月的每周提交效率"
} else if (aiStore.duration === "months:6") {
return "过去半年的每月提交效率"
} else if (aiStore.duration === "years:1") {
return "过去一年的每月提交效率"
} else {
return "过去四周的提交效率"
}
})
// 判断是否有数据
const show = computed(() => {
return aiStore.durationData.length > 0
})
// 计算提交效率数据
const efficiencyData = computed(() => {
return aiStore.durationData.map((duration) => {
const problemCount = duration.problem_count || 0
const submissionCount = duration.submission_count || 0
// 计算效率:提交次数/完成题目数
// 值越接近1说明一次AC率越高
const efficiency = problemCount > 0 ? submissionCount / problemCount : 0
// AC率AC题目数 / 总提交次数(越高说明提交质量越好)
const onePassRate =
submissionCount > 0 ? (problemCount / submissionCount) * 100 : 0
return {
label: [
parseTime(duration.start, "M月D日"),
parseTime(duration.end, "M月D日"),
].join(""),
efficiency: efficiency,
onePassRate: onePassRate,
problemCount: problemCount,
submissionCount: submissionCount,
}
})
})
// 图表数据
const data = computed<ChartData<"line">>(() => {
const efficiency = efficiencyData.value
return {
labels: efficiency.map((e) => e.label),
datasets: [
{
label: "平均提交次数",
data: efficiency.map((e) => e.efficiency),
borderColor: "rgb(99, 102, 241)",
backgroundColor: "rgba(99, 102, 241, 0.1)",
tension: 0.4,
fill: true,
pointRadius: 5,
pointHoverRadius: 7,
borderWidth: 2.5,
pointBackgroundColor: "rgb(99, 102, 241)",
pointBorderColor: "#fff",
pointBorderWidth: 2,
yAxisID: "y",
},
{
label: "提交AC率",
data: efficiency.map((e) => e.onePassRate),
borderColor: "rgb(34, 197, 94)",
backgroundColor: "rgba(34, 197, 94, 0.1)",
tension: 0.4,
fill: true,
pointRadius: 5,
pointHoverRadius: 7,
borderWidth: 2.5,
pointBackgroundColor: "rgb(34, 197, 94)",
pointBorderColor: "#fff",
pointBorderWidth: 2,
yAxisID: "y1",
},
],
}
})
// 图表配置
const options = computed(() => {
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index" as const,
intersect: false,
},
scales: {
x: {
ticks: {
maxRotation: 0,
minRotation: 0,
autoSkip: true,
},
},
y: {
type: "linear" as const,
position: "left" as const,
title: {
display: true,
text: "平均提交次数(次/题)",
font: {
size: 13,
},
},
beginAtZero: true,
ticks: {
callback: function (value: string | number) {
return Number(value).toFixed(1)
},
},
},
y1: {
type: "linear" as const,
position: "right" as const,
min: 0,
max: 100,
title: {
display: true,
text: "提交AC率%",
font: {
size: 13,
},
},
ticks: {
callback: function (value: string | number) {
return Number(value).toFixed(0) + "%"
},
},
grid: {
drawOnChartArea: false,
},
},
},
plugins: {
title: {
display: false,
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 12,
callbacks: {
label: function (ctx: TooltipItem<"line">) {
const index = ctx.dataIndex
const item = efficiencyData.value[index]
const dsLabel = ctx.dataset.label || ""
if (ctx.datasetIndex === 0) {
// 平均提交次数
return [
`${dsLabel}: ${item.efficiency.toFixed(2)} 次/题`,
`完成题目: ${item.problemCount}`,
`总提交: ${item.submissionCount}`,
]
} else {
// 提交AC率
return [
`${dsLabel}: ${item.onePassRate.toFixed(1)}%`,
`提示: AC题目数 / 总提交次数,越高表示提交质量越好`,
]
}
},
},
},
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 12,
},
},
},
},
}
})
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div align="center" style="display: inline-flex; margin: 0 10px">
<img src="/S.png" alt="S Grade" v-if="props.grade === 'S'" />
<img src="/A.png" alt="A Grade" v-if="props.grade === 'A'" />
<img src="/B.png" alt="B Grade" v-if="props.grade === 'B'" />
<img src="/C.png" alt="C Grade" v-if="props.grade === 'C'" />
<n-tooltip trigger="hover">
<template #trigger>
<n-icon size="16" style="cursor: help">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"
/>
</svg>
</n-icon>
</template>
<div style="max-width: 300px; line-height: 1.4">
<div style="font-weight: bold; margin-bottom: 8px">等级计算说明</div>
<div>使用加权平均方法计算综合等级</div>
<div> S级 = 4A级 = 3B级 = 2C级 = 1</div>
<div> 根据平均分数确定最终等级</div>
<div>- S级3.5</div>
<div>- A级2.5-3.5</div>
<div>- B级1.5-2.5</div>
<div>- C级<1.5分</div>
</div>
</n-tooltip>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
grade: "S" | "A" | "B" | "C"
}>()
</script>
<style scoped>
img {
animation: shake 0.5s infinite;
width: 30px;
height: 30px;
}
@keyframes shake {
0% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-10px) scale(1.1);
}
100% {
transform: translateY(0) scale(1);
}
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<n-card title="过去一年的提交热力图" size="small">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
</template>
<n-spin :show="aiStore.loading.heatmap" :delay="50">
<div class="heatmap-container" ref="containerRef">
<svg
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
preserveAspectRatio="xMinYMin meet"
class="heatmap-svg"
>
<g v-for="label in monthLabels" :key="`${label.text}-${label.x}`">
<text :x="label.x" :y="10" class="label" font-size="10">
{{ label.text }}
</text>
</g>
<g v-for="(day, i) in WEEK_DAYS" :key="i">
<text
:x="0"
:y="MONTH_HEIGHT + i * CELL_TOTAL + 8"
class="label"
font-size="9"
>
{{ day }}
</text>
</g>
<g :transform="`translate(${DAY_WIDTH}, ${MONTH_HEIGHT})`">
<rect
v-for="(cell, i) in cells"
:key="i"
:x="cell.x"
:y="cell.y"
:width="CELL_SIZE"
:height="CELL_SIZE"
:fill="cell.color"
class="cell"
rx="2"
@mouseenter="(e) => showTooltip(e, cell)"
@mouseleave="hideTooltip"
/>
</g>
</svg>
<div v-if="tooltip" class="tooltip" :style="tooltipStyle">
<div class="tooltip-date">{{ tooltip.date }}</div>
<div class="tooltip-count" :class="{ active: tooltip.count > 0 }">
{{ tooltip.text }}
</div>
</div>
</div>
</n-spin>
</n-card>
</template>
<script setup lang="ts">
import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions"
const aiStore = useAIStore()
const containerRef = useTemplateRef<HTMLElement>("containerRef")
const CELL_SIZE = 12
const CELL_GAP = 3
const CELL_TOTAL = CELL_SIZE + CELL_GAP
const DAY_WIDTH = 20
const MONTH_HEIGHT = 20
const RIGHT_PADDING = 5
const COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
const WEEK_DAYS = ["", "一", "", "三", "", "五", ""]
const getColor = (count: number) =>
count === 0
? COLORS[0]
: count <= 2
? COLORS[1]
: count <= 4
? COLORS[2]
: count <= 7
? COLORS[3]
: COLORS[4]
const cells = computed(() =>
aiStore.heatmapData.map((item, i) => ({
date: new Date(item.timestamp),
count: item.value,
color: getColor(item.value),
week: Math.floor(i / 7),
day: i % 7,
x: Math.floor(i / 7) * CELL_TOTAL,
y: (i % 7) * CELL_TOTAL,
})),
)
const monthLabels = computed(() => {
const labels: { text: string; x: number }[] = []
let lastMonth = -1
cells.value.forEach((cell, i) => {
const month = cell.date.getMonth()
const isWeekStart = cell.date.getDay() === 0 || i === 0
if (month !== lastMonth && (isWeekStart || cell.date.getDay() <= 3)) {
labels.push({
text: `${month + 1}`,
x: DAY_WIDTH + cell.week * CELL_TOTAL,
})
lastMonth = month
}
})
return labels
})
const svgWidth = computed(
() =>
DAY_WIDTH + Math.ceil(cells.value.length / 7) * CELL_TOTAL + RIGHT_PADDING,
)
const svgHeight = computed(() => MONTH_HEIGHT + 7 * CELL_TOTAL)
interface Cell {
date: Date
count: number
color: string
week: number
day: number
x: number
y: number
}
const tooltip = ref<{
x: number
y: number
date: string
text: string
count: number
} | null>(null)
const tooltipStyle = computed(() => ({
left: `${tooltip.value?.x}px`,
top: `${tooltip.value?.y}px`,
}))
const getTooltipText = (count: number) =>
count === 0 ? "没有提交记录" : `提交了 ${count}`
const showTooltip = (e: MouseEvent, cell: Cell) => {
const rect = (e.target as HTMLElement).getBoundingClientRect()
const containerRect = containerRef.value?.getBoundingClientRect()
if (containerRect) {
tooltip.value = {
x: rect.left - containerRect.left + rect.width / 2,
y: rect.top - containerRect.top - 10,
date: parseTime(cell.date, "YYYY年M月D日"),
text: getTooltipText(cell.count),
count: cell.count,
}
}
}
const hideTooltip = () => {
tooltip.value = null
}
</script>
<style scoped>
.heatmap-container {
width: 100%;
padding: 10px 0;
position: relative;
}
.heatmap-svg {
width: 100%;
height: auto;
display: block;
}
.label {
fill: currentColor;
opacity: 0.7;
}
.cell {
cursor: pointer;
transition: all 0.2s ease;
stroke: rgba(0, 0, 0, 0.05);
stroke-width: 0.5;
}
.cell:hover {
stroke: rgba(0, 0, 0, 0.3);
stroke-width: 1.5;
filter: brightness(0.9);
}
.tooltip {
position: absolute;
transform: translate(-50%, -100%);
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: fade-in 0.2s ease;
}
.tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(0, 0, 0, 0.9);
}
.tooltip-date {
font-weight: 500;
margin-bottom: 2px;
}
.tooltip-count {
opacity: 0.6;
}
.tooltip-count.active {
color: #7bc96f;
opacity: 0.9;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translate(-50%, calc(-100% - 5px));
}
to {
opacity: 1;
transform: translate(-50%, -100%);
}
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<n-alert
:show-icon="false"
type="success"
v-if="aiStore.detailsData.solved.length"
>
<span>{{ durationLabel }}</span>
<span>你一共解决 </span>
<b class="charming"> {{ aiStore.detailsData.solved.length }} </b>
<span> 道题</span>
<span v-if="aiStore.detailsData.contest_count > 0">
并且参加
<b class="charming"> {{ aiStore.detailsData.contest_count }} </b>
次比赛
</span>
<span>综合评价给到</span>
<Grade :grade="aiStore.detailsData.grade" />
<span>{{ greeting }}</span>
</n-alert>
<n-flex vertical size="large" v-else>
<n-alert type="error" title="你还没有完成任何题目">
开始解题看看你的学习能力吧
</n-alert>
<AI />
</n-flex>
</template>
<script lang="ts" setup>
import Grade from "./Grade.vue"
import { parseTime } from "utils/functions"
import { useAIStore } from "oj/store/ai"
import AI from "./AI.vue"
const aiStore = useAIStore()
const durationLabel = computed(() => {
if (aiStore.duration.includes("hours")) {
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")
) {
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日")} 期间`
}
})
const greeting = computed(() => {
return {
S: "要不试试高难度题目?",
A: "你很棒,继续保持!",
B: "请再接再厉!",
C: "你还需要努力!",
}[aiStore.detailsData.grade]
})
</script>
<style scoped>
.charming {
font-size: 1.2rem;
}
</style>

View File

@@ -0,0 +1,271 @@
<template>
<n-card :title="title" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">追踪学习成长轨迹</n-text>
</template>
<div class="chart">
<Chart type="line" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
import { Chart } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Colors,
Filler,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions"
import type { Grade } from "utils/types"
// 注册折线图所需的 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Colors,
Filler,
)
const aiStore = useAIStore()
const gradeOrder = ["C", "B", "A", "S"] as const
const gradeColors: Record<Grade, string> = {
C: "#95F204",
B: "#36A2EB",
A: "#FFCE56",
S: "#FF6384",
}
const title = computed(() => {
if (aiStore.duration === "months:2") {
return "过去两个月的进步曲线"
} else if (aiStore.duration === "months:6") {
return "过去半年的进步曲线"
} else if (aiStore.duration === "years:1") {
return "过去一年的进步曲线"
} else {
return "过去四周的进步曲线"
}
})
// 判断是否有数据
const show = computed(() => {
return aiStore.durationData.length > 0
})
// 计算累计题目数量和等级趋势
const progressData = computed(() => {
let cumulativeCount = 0
let totalWeightedGrade = 0 // 累计加权等级
let totalProblems = 0 // 累计题目总数
return aiStore.durationData.map((duration) => {
const problemCount = duration.problem_count || 0
cumulativeCount += problemCount
// 计算本期等级的权重值
const currentGradeValue = gradeOrder.indexOf(duration.grade || "C")
// 累加加权等级
totalWeightedGrade += currentGradeValue * problemCount
totalProblems += problemCount
// 计算累计平均等级
const avgGradeValue =
totalProblems > 0 ? totalWeightedGrade / totalProblems : 0
return {
label: [
parseTime(duration.start, "M月D日"),
parseTime(duration.end, "M月D日"),
].join(""),
start: parseTime(duration.start, "YYYY-MM-DD"),
end: parseTime(duration.end, "YYYY-MM-DD"),
count: cumulativeCount,
grade: duration.grade || "C",
gradeValue: currentGradeValue,
avgGradeValue: avgGradeValue, // 累计平均等级
problemCount: problemCount,
}
})
})
// 图表数据
const data = computed<ChartData<"line">>(() => {
const progress = progressData.value
return {
labels: progress.map((p) => p.label),
datasets: [
{
type: "line",
label: "累计完成题目",
data: progress.map((p) => p.count),
borderColor: "#4CAF50",
backgroundColor: "rgba(76, 175, 80, 0.1)",
tension: 0.4,
yAxisID: "y",
fill: true,
pointRadius: 5,
pointHoverRadius: 7,
borderWidth: 2.5,
pointBackgroundColor: "#4CAF50",
pointBorderColor: "#fff",
pointBorderWidth: 2,
},
{
type: "line",
label: "累计平均等级",
data: progress.map((p) => p.avgGradeValue),
borderColor: "#FF9800",
backgroundColor: "rgba(255, 152, 0, 0.1)",
tension: 0.4,
yAxisID: "y1",
fill: false,
pointRadius: 5,
pointHoverRadius: 7,
borderWidth: 2.5,
pointBackgroundColor: progress.map((p) => gradeColors[p.grade]),
pointBorderColor: "#fff",
pointBorderWidth: 2,
},
],
}
})
// 图表配置
const options = computed<ChartOptions<"line">>(() => {
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index",
intersect: false,
},
scales: {
x: {
ticks: {
maxRotation: 0,
minRotation: 0,
autoSkip: true,
maxTicksLimit: 15,
},
},
y: {
type: "linear",
position: "left",
title: {
display: true,
text: "累计题目数",
font: {
size: 14,
},
},
ticks: {
stepSize: 1,
},
beginAtZero: true,
},
y1: {
type: "linear",
position: "right",
min: -0.5,
max: gradeOrder.length - 0.5,
title: {
display: true,
text: "累计平均等级",
font: {
size: 14,
},
},
ticks: {
stepSize: 1,
callback: (v: string | number) => {
const idx = Math.round(Number(v))
return gradeOrder[idx] || ""
},
},
grid: {
drawOnChartArea: false,
},
},
},
plugins: {
title: {
display: false,
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 12,
callbacks: {
title: (items: TooltipItem<"line">[]) => {
if (items.length > 0) {
const idx = items[0].dataIndex
const progress = progressData.value[idx]
return progress ? `${progress.start} ~ ${progress.end}` : ""
}
return ""
},
label: (ctx: TooltipItem<"line">) => {
const dsLabel = ctx.dataset.label || ""
const idx = ctx.dataIndex
const progress = progressData.value[idx]
if (!progress) {
return `${dsLabel}: ${ctx.formattedValue}`
}
if ((ctx.dataset as any).yAxisID === "y1") {
// 累计平均等级轴
const avgIdx = Math.round(Number(ctx.parsed.y))
return [
`${dsLabel}: ${gradeOrder[avgIdx] || ""}`,
`本期等级: ${progress.grade}`,
`本期完成: ${progress.problemCount}`,
]
} else {
// 累计题目数轴
return [
`${dsLabel}: ${ctx.formattedValue}`,
`本期完成: ${progress.problemCount}`,
]
}
},
},
},
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 12,
},
},
},
},
}
})
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,132 @@
<template>
<n-card title="同期解题排名分布" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">
了解同期解题速度和竞争力
</n-text>
</template>
<div style="height: 300px">
<Pie :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Pie } from "vue-chartjs"
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
import { useAIStore } from "oj/store/ai"
ChartJS.register(ArcElement, Title, Tooltip, Legend)
const aiStore = useAIStore()
// 排名区间定义
const RANK_RANGES = [
{ label: "前10%", min: 0, max: 10, color: "#FF6384" },
{ label: "10-30%", min: 10, max: 30, color: "#FFCE56" },
{ label: "30-50%", min: 30, max: 50, color: "#36A2EB" },
{ label: "50-70%", min: 50, max: 70, color: "#4BC0C0" },
{ label: "70%以后", min: 70, max: 100, color: "#9966FF" },
]
// 计算每道题的排名百分位并分类
const rankDistribution = computed(() => {
const distribution = RANK_RANGES.map((range) => ({
...range,
count: 0,
problems: [] as string[],
}))
aiStore.detailsData.solved.forEach((item) => {
const rank = item.period_rank
const acCount = item.period_ac_count
if (rank && acCount && acCount > 0) {
const percentile = (rank / acCount) * 100
// 找到对应的区间
const rangeIndex = RANK_RANGES.findIndex(
(r) => percentile >= r.min && percentile < r.max,
)
if (rangeIndex !== -1) {
distribution[rangeIndex].count++
distribution[rangeIndex].problems.push(
`${item.problem.display_id}: ${item.problem.title}`,
)
}
}
})
return distribution
})
const show = computed(() => {
return aiStore.detailsData.solved.length > 0
})
const data = computed(() => {
return {
labels: RANK_RANGES.map((r) => r.label),
datasets: [
{
label: "题目数量",
data: rankDistribution.value.map((r) => r.count),
backgroundColor: RANK_RANGES.map((r) => r.color),
borderColor: RANK_RANGES.map((r) => r.color),
borderWidth: 1,
},
],
}
})
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 12,
},
},
},
title: {
display: false,
},
tooltip: {
callbacks: {
label: (context: any) => {
const count = context.parsed
const total = rankDistribution.value.reduce(
(sum, r) => sum + r.count,
0,
)
const percentage =
total > 0 ? ((count / total) * 100).toFixed(1) : "0.0"
const label = context.label || ""
return `${label}: ${count} 道题 (${percentage}%)`
},
afterLabel: (context: any) => {
const index = context.dataIndex
const problems = rankDistribution.value[index].problems
if (problems.length > 0 && problems.length <= 5) {
return problems
} else if (problems.length > 5) {
return [
...problems.slice(0, 3),
`... 还有 ${problems.length - 3} 道题`,
]
}
return ""
},
},
},
},
}
</script>

View File

@@ -0,0 +1,154 @@
<template>
<n-tabs animated v-if="submissions.length && flowcharts.length">
<n-tab-pane name="代码提交">
<n-data-table
striped
:data="submissions"
:columns="columns"
:max-height="isDesktop ? 1500 : 500"
/>
</n-tab-pane>
<n-tab-pane name="流程图提交">
<n-data-table
striped
:data="flowcharts"
:columns="flowchartsColumns"
:max-height="isDesktop ? 1500 : 500"
/>
</n-tab-pane>
</n-tabs>
<n-data-table
v-else-if="submissions.length"
striped
:data="submissions"
:columns="columns"
:max-height="isDesktop ? 1500 : 500"
/>
<n-data-table
v-else-if="flowcharts.length"
striped
:data="flowcharts"
:columns="flowchartsColumns"
:max-height="isDesktop ? 1500 : 500"
/>
</template>
<script lang="ts" setup>
import { NButton, NTooltip } from "naive-ui"
import TagTitle from "./TagTitle.vue"
import type { FlowchartSummary, SolvedProblem } from "utils/types"
import { useAIStore } from "oj/store/ai"
import { useBreakpoints } from "shared/composables/breakpoints"
import { parseTime } from "utils/functions"
const router = useRouter()
const aiStore = useAIStore()
const { isDesktop } = useBreakpoints()
const submissions = computed(() => aiStore.detailsData.solved)
const flowcharts = computed(() => aiStore.detailsData.flowcharts)
const columns: DataTableColumn<SolvedProblem>[] = [
{
title: "完成的题目",
key: "problem.title",
render: (row) =>
h(
NButton,
{
text: true,
onClick: () => {
if (row.problem.contest_id) {
router.push(
"/contest/" +
row.problem.contest_id +
"/problem/" +
row.problem.display_id,
)
} else {
router.push("/problem/" + row.problem.display_id)
}
},
},
() => {
if (row.problem.contest_id) {
return h(TagTitle, { problem: row.problem })
} else {
return row.problem.display_id + " " + row.problem.title
}
},
),
},
{
title: () => (aiStore.detailsData.class_name ? "班级排名" : "全服排名"),
key: "rank",
width: 100,
align: "center",
render: (row) => row.rank + " / " + row.ac_count,
},
{
title: "同期排名",
key: "period_rank",
width: 100,
align: "center",
render: (row) => row.period_rank + " / " + row.period_ac_count,
},
{
title: () =>
h(NTooltip, null, {
trigger: () =>
h(
"span",
{ style: "cursor:help; border-bottom: 1px dashed" },
"等级",
),
default: () =>
h("div", null, [
h("div", null, "基于同时段排名的百分位:"),
h("div", null, "S — 前 10%"),
h("div", null, "A — 前 35%"),
h("div", null, "B — 前 75%"),
h("div", null, "C — 其余"),
]),
}),
key: "grade",
width: 100,
align: "center",
},
]
const flowchartsColumns: DataTableColumn<FlowchartSummary>[] = [
{
title: "完成的题目",
key: "problem_title",
width: 300,
render: (row) =>
h(
NButton,
{
text: true,
onClick: () => {
router.push("/problem/" + row.problem__id)
},
},
() => `${row.problem__id} ${row.problem_title}`,
),
},
{ title: "提交次数", key: "submission_count", width: 100, align: "center" },
{
title: "最高分",
key: "best",
width: 100,
align: "center",
render: (row) => `${row.best_score} (${row.best_grade})`,
},
{
title: "最新提交时间",
key: "latest_submission_time",
width: 200,
align: "center",
render: (row) => parseTime(row.latest_submission_time),
},
{ title: "平均分", key: "avg_score", width: 100, align: "center" },
]
</script>

View File

@@ -0,0 +1,177 @@
<template>
<n-card title="连续做题统计" size="small">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
</template>
<n-spin :show="aiStore.loading.heatmap" :delay="50">
<n-grid :cols="2" :x-gap="12" :y-gap="12">
<n-gi>
<n-statistic label="当前连续" :value="currentStreak">
<template #suffix>
<span style="font-size: 14px"></span>
<span
v-if="currentStreak > 0"
style="font-size: 20px; margin-left: 4px"
>
🔥
</span>
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic label="最长连续" :value="maxStreak">
<template #suffix>
<span style="font-size: 14px"></span>
<span
v-if="maxStreak >= 7"
style="font-size: 20px; margin-left: 4px"
>
</span>
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic label="本周做题" :value="weekCount">
<template #suffix>
<span style="font-size: 14px"></span>
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic label="本月做题" :value="monthCount">
<template #suffix>
<span style="font-size: 14px"></span>
</template>
</n-statistic>
</n-gi>
</n-grid>
<n-divider style="margin: 12px 0" />
<n-flex vertical size="small">
<n-text depth="2" style="font-size: 12px">
<span v-if="currentStreak === 0"> 开始做题建立学习连续记录 </span>
<span v-else-if="currentStreak < 3"> 继续保持争取连续3天 </span>
<span v-else-if="currentStreak < 7">
很棒继续保持一周连续记录
</span>
<span v-else-if="currentStreak < 30">
太棒了坚持满30天将获得持之以恒成就
</span>
<span v-else>
🎉 恭喜你你已经连续学习 {{ currentStreak }} 真的非常厉害
</span>
</n-text>
</n-flex>
</n-spin>
</n-card>
</template>
<script setup lang="ts">
import { useAIStore } from "oj/store/ai"
const aiStore = useAIStore()
// 计算连续天数
const streakData = computed(() => {
const heatmap = aiStore.heatmapData
if (!heatmap || heatmap.length === 0) {
return {
currentStreak: 0,
maxStreak: 0,
weekCount: 0,
monthCount: 0,
}
}
// 按时间戳排序
const sortedData = [...heatmap].sort((a, b) => a.timestamp - b.timestamp)
let currentStreak = 0
let maxStreak = 0
let tempStreak = 0
let lastDate: Date | null = null
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
let weekCount = 0
let monthCount = 0
// 检查今天是否有做题
const todayData = sortedData.find((item) => {
const itemDate = new Date(item.timestamp)
return (
itemDate.getFullYear() === today.getFullYear() &&
itemDate.getMonth() === today.getMonth() &&
itemDate.getDate() === today.getDate()
)
})
const hasToday = todayData && todayData.value > 0
// 遍历数据计算连续天数
for (const item of sortedData) {
if (item.value > 0) {
const currentDate = new Date(item.timestamp)
// 统计本周和本月
if (currentDate >= weekAgo) {
weekCount++
}
if (currentDate >= monthAgo) {
monthCount++
}
if (lastDate === null) {
tempStreak = 1
} else {
const dayDiff = Math.floor(
(currentDate.getTime() - lastDate.getTime()) / (24 * 60 * 60 * 1000),
)
if (dayDiff === 1) {
tempStreak++
} else {
maxStreak = Math.max(maxStreak, tempStreak)
tempStreak = 1
}
}
lastDate = currentDate
}
}
maxStreak = Math.max(maxStreak, tempStreak)
// 计算当前连续天数(必须包含今天或昨天)
if (lastDate) {
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000)
const lastDateOnly = new Date(
lastDate.getFullYear(),
lastDate.getMonth(),
lastDate.getDate(),
)
if (
lastDateOnly.getTime() === today.getTime() ||
lastDateOnly.getTime() === yesterday.getTime()
) {
currentStreak = tempStreak
} else {
currentStreak = 0
}
}
return {
currentStreak,
maxStreak,
weekCount,
monthCount,
}
})
const currentStreak = computed(() => streakData.value.currentStreak)
const maxStreak = computed(() => streakData.value.maxStreak)
const weekCount = computed(() => streakData.value.weekCount)
const monthCount = computed(() => streakData.value.monthCount)
</script>

View File

@@ -0,0 +1,21 @@
<template>
<n-flex vertical align="start">
<n-flex align="center">
<n-tag type="info" size="small" :bordered="false">比赛</n-tag>
<span>{{ problem.contest_title }}</span>
</n-flex>
<span>{{ problem.display_id }} {{ problem.title }}</span>
</n-flex>
</template>
<script setup lang="ts">
interface Props {
problem: {
title: string
display_id: string
contest_title: string
contest_id: number
}
}
const props = defineProps<Props>()
</script>

View File

@@ -0,0 +1,154 @@
<template>
<n-card :title="title" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">可视化知识点覆盖面</n-text>
</template>
<div class="chart">
<Radar :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Radar } from "vue-chartjs"
import {
Chart as ChartJS,
RadialLinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
// 注册雷达图所需的 Chart.js 组件
ChartJS.register(
RadialLinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend,
)
const aiStore = useAIStore()
const show = computed(() => {
return Object.keys(aiStore.detailsData.tags).length > 0
})
// 最多显示前10个标签避免雷达图过于拥挤
const MAX_TAGS = 10
const title = computed(() => {
const totalTags = Object.keys(aiStore.detailsData.tags).length
const displayTags = Math.min(totalTags, MAX_TAGS)
return `标签雷达图(前${displayTags}个)`
})
// 计算归一化的数据(用于雷达图展示)
const normalizedData = computed(() => {
const tags = aiStore.detailsData.tags
// 按题目数量降序排序取前MAX_TAGS个
const sortedTags = Object.entries(tags)
.sort(([, a], [, b]) => b - a)
.slice(0, MAX_TAGS)
const values = sortedTags.map(([, value]) => value)
const maxValue = Math.max(...values, 1) // 避免除以0
// 归一化到0-100的范围
return sortedTags.map(([label, value]) => ({
label,
value,
normalized: (value / maxValue) * 100,
}))
})
const data = computed(() => {
const tagData = normalizedData.value
return {
labels: tagData.map((item) => item.label),
datasets: [
{
label: "掌握程度",
data: tagData.map((item) => item.normalized),
backgroundColor: "rgba(99, 102, 241, 0.25)",
borderColor: "rgb(99, 102, 241)",
borderWidth: 2.5,
pointBackgroundColor: "rgb(99, 102, 241)",
pointBorderColor: "#fff",
pointHoverBackgroundColor: "#fff",
pointHoverBorderColor: "rgb(99, 102, 241)",
pointRadius: 5,
pointHoverRadius: 7,
pointBorderWidth: 2,
},
],
}
})
const options = computed(() => {
const tagData = normalizedData.value
return {
responsive: true,
maintainAspectRatio: false,
scales: {
r: {
beginAtZero: true,
max: 100,
min: 0,
ticks: {
stepSize: 20,
backdropColor: "transparent",
callback: function (value: string | number) {
return Number(value) + "%"
},
font: {
size: 11,
},
},
grid: {
color: "rgba(0, 0, 0, 0.1)",
circular: true,
},
angleLines: {
color: "rgba(0, 0, 0, 0.1)",
},
pointLabels: {
font: {
size: 13,
weight: 500 as const,
},
padding: 10,
},
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
callbacks: {
label: function (context: any) {
const index = context.dataIndex
const actualValue = tagData[index].value
const percentage = Math.round(Number(context.parsed.r))
return `完成 ${actualValue} 道题 (掌握度 ${percentage}%)`
},
},
},
},
}
})
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<n-card title="时间活跃度分析" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">
基于 AC 时间发现解题高峰时段
</n-text>
</template>
<div style="height: 300px">
<Bar :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Bar } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const aiStore = useAIStore()
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
const TIME_PERIODS = [
{ label: "凌晨(0-6)", start: 0, end: 6 },
{ label: "上午(6-12)", start: 6, end: 12 },
{ label: "下午(12-18)", start: 12, end: 18 },
{ label: "晚上(18-24)", start: 18, end: 24 },
]
// 统计每个星期几和时间段的做题数量
const activityMatrix = computed(() => {
const matrix: { [weekday: number]: { [period: number]: number } } = {}
// 初始化矩阵
for (let i = 0; i < 7; i++) {
matrix[i] = {}
for (let j = 0; j < TIME_PERIODS.length; j++) {
matrix[i][j] = 0
}
}
// 统计数据
aiStore.detailsData.solved.forEach((item) => {
const date = new Date(item.ac_time)
const weekday = date.getDay() // 0-60是周日
const hour = date.getHours() // 0-23
// 找到对应的时间段
const periodIndex = TIME_PERIODS.findIndex(
(p) => hour >= p.start && hour < p.end,
)
if (periodIndex !== -1) {
matrix[weekday][periodIndex]++
}
})
return matrix
})
const show = computed(() => {
return aiStore.detailsData.solved.length > 0
})
// 为每个时间段准备数据集
const data = computed(() => {
const datasets = TIME_PERIODS.map((period, periodIndex) => {
return {
label: period.label,
data: WEEKDAYS.map(
(_, weekday) => activityMatrix.value[weekday][periodIndex],
),
backgroundColor: getTimePeriodColor(periodIndex),
borderColor: getTimePeriodColor(periodIndex),
borderWidth: 1,
}
})
return {
labels: WEEKDAYS,
datasets,
}
})
// 根据时间段返回对应的颜色
function getTimePeriodColor(periodIndex: number): string {
const colors = [
"#9D9D9D", // 凌晨 - 灰色
"#FFD700", // 上午 - 金色
"#4ECDC4", // 下午 - 青色
"#5B5F97", // 晚上 - 深蓝紫
]
return colors[periodIndex] || "#999"
}
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index" as const,
},
scales: {
x: {
stacked: true,
grid: {
display: false,
},
},
y: {
stacked: true,
ticks: {
stepSize: 1,
},
title: {
display: true,
text: "完成题目数",
},
},
},
plugins: {
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
padding: 8,
font: {
size: 11,
},
},
},
title: {
display: false,
},
tooltip: {
callbacks: {
footer: (items: any[]) => {
const total = items.reduce((sum, item) => sum + item.parsed.y, 0)
return `当天总计: ${total}`
},
},
},
},
}
</script>

View File

@@ -0,0 +1,14 @@
<template>
<n-flex align="center">
<n-tag type="error" v-if="top">置顶</n-tag>
<span>{{ title }}</span>
</n-flex>
</template>
<script setup lang="ts">
interface Props {
top: boolean
title: string
}
defineProps<Props>()
</script>

View File

@@ -0,0 +1,94 @@
<script lang="ts" setup>
import { NTag } from "naive-ui"
import { getAnnouncement, getAnnouncementList } from "oj/api"
import Pagination from "shared/components/Pagination.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { parseTime } from "utils/functions"
import { renderTableTitle } from "utils/renders"
import type { Announcement } from "utils/types"
import TitleWithTag from "./components/TitleWithTag.vue"
const total = ref(0)
const content = ref("")
const title = ref("")
const [show, toggleShow] = useToggle(false)
const { isDesktop } = useBreakpoints()
const query = reactive({
limit: 10,
page: 1,
})
const columns: DataTableColumn<Announcement>[] = [
{
key: "title",
title: renderTableTitle("公告标题", "streamline-emojis:fire"),
render: (row) => h(TitleWithTag, { title: row.title, top: row.top }),
minWidth: 300,
},
{
key: "tag",
title: renderTableTitle("标签", "fluent-emoji-flat:keycap-hashtag"),
width: 100,
render: (row) => h(NTag, () => row.tag || "公告"),
},
{
key: "create_time",
title: renderTableTitle("发布时间", "fluent-emoji-flat:eight-oclock"),
render: (row) => parseTime(row.create_time),
width: 180,
},
{
key: "username",
title: renderTableTitle("发布人", "streamline-emojis:ghost"),
render: (row) => row.created_by.username,
width: 120,
},
]
function rowProps(row: Announcement) {
return {
style: "cursor: pointer",
onclick: () => showContent(row),
}
}
async function showContent(announcement: Announcement) {
const res = await getAnnouncement(announcement.id)
toggleShow(true)
title.value = announcement.title
content.value = res.data.content
}
const announcements = ref<Announcement[]>([])
async function listAnnouncements() {
const offset = (query.page - 1) * query.limit
const res = await getAnnouncementList(offset, query.limit)
total.value = res.data.total
announcements.value = res.data.results
}
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-modal
v-model:show="show"
preset="card"
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
:title="title"
>
<div v-html="content"></div>
</n-modal>
</template>

435
apps/web/src/oj/api.ts Normal file
View File

@@ -0,0 +1,435 @@
import http from "utils/http"
import { filterResult } from "oj/transforms"
import type {
Exercise,
Problem,
ReactionKey,
ReactionState,
Submission,
SubmissionListPayload,
SubmitCodePayload,
} from "utils/types"
export function getWebsiteConfig() {
return http.get("website")
}
export async function getProblemList(
offset = 0,
limit = 10,
searchParams: any = {},
) {
const res = await http.get<{ results: Problem[]; total: number }>("problem", {
params: { paging: true, offset, limit, ...searchParams },
})
return {
results: res.data.results.map(filterResult),
total: res.data.total,
}
}
export function getAuthors(all = false) {
return http.get("problem/author", {
params: {
all: all ? "1" : "0",
},
})
}
export function getRandomProblemID() {
return http.get("pickone")
}
export function getProblem(problemID: string, contestID: string) {
const endpoint = !!contestID ? "contest/problem" : "problem"
return http.get(endpoint, {
params: {
problem_id: problemID,
contest_id: contestID,
},
})
}
export function getProblemBeatRate(problemID: number) {
return http.get("problem/beat_count", { params: { problem_id: problemID } })
}
export function getSubmission(id: string) {
return http.get<Submission>("submission", {
params: { id },
})
}
export function submitCode(data: SubmitCodePayload) {
return http.post("submission", data)
}
export function formatCode(data: { code: string; language: string }) {
return http.post<{ code: string }>("format_code", data)
}
export function getSubmissions(params: Partial<SubmissionListPayload>) {
const endpoint = !!params.contest_id ? "contest_submissions" : "submissions"
return http.get(endpoint, { params })
}
export function getRankOfProblem(problem_id: string) {
return http.get("user_problem_rank", { params: { problem_id: problem_id } })
}
export function getTodaySubmissionCount(language?: string) {
return http.get("submissions/today_count", { params: { language } })
}
export function adminRejudge(id: string) {
return http.get("admin/submission/rejudge", {
params: { id },
})
}
export function getSubmissionStatistics(
duration: { start?: string; end: string },
problemID?: string,
username?: string,
) {
return http.get("admin/submission/statistics", {
params: {
...duration,
problem_id: problemID,
username,
},
})
}
export function getRank(
offset: number,
limit: number,
n: number,
username?: string,
) {
return http.get("user_rank", {
params: { offset, limit, rule: "acm", username, n },
})
}
export function getActivityRank(start: string) {
return http.get("user_activity_rank", {
params: { start },
})
}
export function getClassRank(grade?: number | null) {
return http.get("class_rank", {
params: { grade },
})
}
export function getUserClassRank(
scope?: "all" | "window",
offset?: number,
limit?: number,
) {
return http.get("user_class_rank", { params: { scope, offset, limit } })
}
export function getClassPK(
classNames: string[],
startTime?: string,
endTime?: string,
) {
const payload: any = {
class_name: classNames,
}
if (startTime) {
payload.start_time = startTime
}
if (endTime) {
payload.end_time = endTime
}
return http.post("class_pk", payload)
}
export function getContestList(query: {
offset: number
limit: number
keyword: string
status: string
tag: string
}) {
return http.get("contests", { params: query })
}
export function getContest(id: string) {
return http.get("contest", { params: { id } })
}
export function getContestAccess(id: string) {
return http.get("contest/access", { params: { contest_id: id } })
}
export function checkContestPassword(contestID: string, password: string) {
return http.post("contest/password", {
contest_id: contestID,
password,
})
}
export async function getContestProblems(contestID: string) {
const res = await http.get<Problem[]>("contest/problem", {
params: { contest_id: contestID },
})
return res.data.map(filterResult)
}
export function getContestRank(
contestID: string,
query: { limit: number; offset: number },
) {
return http.get("contest_rank", {
params: {
contest_id: contestID,
...query,
},
})
}
export function uploadAvatar(file: File) {
const form = new window.FormData()
form.append("image", file)
return http.post("upload_avatar", form, {
headers: { "content-type": "multipart/form-data" },
})
}
export function updateProfile(data: { real_name: string; mood: string }) {
return http.put("profile", data)
}
export function getAnnouncementList(offset = 0, limit = 10) {
return http.get("announcement", { params: { limit, offset } })
}
export function getAnnouncement(id: number) {
return http.get("announcement", { params: { id } })
}
export function createMessage(data: {
recipient: number
message: string
submission: string
}) {
return http.post("message", data)
}
export function getMessageList(offset = 0, limit = 10) {
return http.get("message", { params: { limit, offset } })
}
export function getReaction(problemID: number) {
return http.get<ReactionState>("reaction", {
params: { problem_id: problemID },
})
}
export function setReaction(problemID: number, type: ReactionKey) {
return http.post<ReactionState>("reaction", {
problem_id: problemID,
type,
})
}
// TODO: 这个API有问题
export function refreshUserProblemDisplayIds() {
return http.get("profile/fresh_display_id")
}
export function getMetrics(userid: number) {
return http.get("metrics", { params: { userid } })
}
export function getTutorial(id: number) {
return http.get("tutorial", { params: { id } })
}
export function getTutorials(type: "python" | "c") {
return http.get("tutorials", { params: { type } })
}
export function getAIDetailData(start: string, end: string, username?: string) {
return http.get("ai/detail", { params: { start, end, username } })
}
export function getAIDurationData(
end: string,
duration: string,
username?: string,
) {
return http.get("ai/duration", { params: { end, duration, username } })
}
export function getAIHeatmapData(username?: string) {
return http.get("ai/heatmap", { params: username ? { username } : {} })
}
export function getAILoginSummary() {
return http.get("ai/login_summary")
}
export function getAIPinnedReport() {
return http.get("ai/pinned")
}
// ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) {
return http.get("problem/similar", { params: { problem_id: problemId } })
}
export interface YearlyACData {
year: number
total: number
accepted: number
ac_rate: number
}
export function getProblemYearlyAC(problemId: string) {
return http.get<YearlyACData[]>("problem/yearly_ac", {
params: { problem_id: problemId },
})
}
// ==================== 流程图相关API ====================
export function submitFlowchart(data: {
problem_id: number
mermaid_code: string
flowchart_data: any // 这个是压缩之后的,元数据太长了
}) {
return http.post("flowchart/submission", data)
}
export function getFlowchartSubmission(id: string) {
return http.get("flowchart/submission", {
params: { id },
})
}
export function getFlowchartSubmissions(params: {
username?: string
problem_id?: string
myself?: string
offset?: number
limit?: number
today?: string
grade?: string
}) {
return http.get("flowchart/submissions", { params })
}
export function getFlowchartStatistics(
duration: { start?: string; end: string },
problemID?: string,
username?: string,
) {
return http.get("admin/flowchart/statistics", {
params: {
...duration,
problem_id: problemID,
username,
},
})
}
export function retryFlowchartSubmission(submissionId: string) {
return http.post("flowchart/submission/retry", {
submission_id: submissionId,
})
}
export function getCurrentProblemFlowchartSubmission(problemId: number) {
return http.get("flowchart/submission/current", {
params: { problem_id: problemId },
})
}
export function getFlowchartSubmissionDetail(problemId: number, page = 0) {
return http.get("flowchart/submission/detail", {
params: { problem_id: problemId, page },
})
}
// ==================== 题单相关API ====================
export function getProblemSetList(
offset = 0,
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
return http.get("problemset", {
params: {
offset,
limit,
keyword,
difficulty,
status,
},
})
}
export function getProblemSetDetail(id: number) {
return http.get(`problemset/${id}`)
}
export function getProblemSetProblems(problemSetId: number) {
return http.get(`problemset/${problemSetId}/problems`)
}
export function joinProblemSet(problemSetId: number) {
return http.post("problemset/progress", {
problemset_id: problemSetId,
})
}
export function updateProblemSetProgress(
problemSetId: number,
problemId: number,
submissionId: string,
) {
return http.put("problemset/progress", {
problemset_id: problemSetId,
problem_id: problemId,
submission_id: submissionId,
})
}
// 获取用户徽章列表
export function getUserBadges(username?: string) {
return http.get("user/badges", { params: username ? { username } : {} })
}
// 获取题单徽章列表
export function getProblemSetBadges(problemSetId: number) {
return http.get(`problemset/${problemSetId}/badges`)
}
// 获取题单用户进度列表
export function getProblemSetUserProgress(
problemSetId: number,
params?: {
limit?: number
offset?: number
class_name?: string
completion_status?: "" | "completed" | "in_progress" | "not_started"
},
) {
return http.get(`problemset/${problemSetId}/users_progress`, { params })
}
export async function getExercises(tutorialId: number): Promise<Exercise[]> {
const res = await http.get<Exercise[]>("exercises", {
params: { tutorial_id: tutorialId },
})
return res.data
}

1260
apps/web/src/oj/class/pk.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
import { ref, provide, inject } from "vue"
/**
* 同步状态管理 composable
* 使用 provide/inject 模式在组件树中共享状态
*/
export interface SyncStatusState {
hadConnection: boolean
otherUser?: { name: string; isSuperAdmin: boolean }
lastLeftUser?: { name: string; isSuperAdmin: boolean } // 保存离开之人的信息
}
// 提供/注入的 key
export const SYNC_STATUS_KEY = Symbol("syncStatus")
/**
* 创建同步状态实例
* 每次调用创建新的状态实例
*/
export function createSyncStatus() {
const otherUser = ref<{ name: string; isSuperAdmin: boolean }>()
const hadConnection = ref(false)
const lastLeftUser = ref<{ name: string; isSuperAdmin: boolean }>()
const setOtherUser = (user?: { name: string; isSuperAdmin: boolean }) => {
// 如果之前有其他用户,现在没有了,说明用户离开了
if (otherUser.value && !user) {
lastLeftUser.value = otherUser.value
}
otherUser.value = user
if (user) {
hadConnection.value = true
}
}
const reset = () => {
otherUser.value = undefined
hadConnection.value = false
lastLeftUser.value = undefined
}
return {
otherUser,
hadConnection,
lastLeftUser,
setOtherUser,
reset,
}
}
/**
* 提供同步状态到子组件
* 在父组件中调用
*/
export function provideSyncStatus() {
const syncStatus = createSyncStatus()
provide(SYNC_STATUS_KEY, syncStatus)
return syncStatus
}
/**
* 注入同步状态
* 在子组件中调用,获取父组件提供的状态
*/
export function injectSyncStatus() {
const syncStatus =
inject<ReturnType<typeof createSyncStatus>>(SYNC_STATUS_KEY)
if (!syncStatus) {
throw new Error("syncStatus must be provided by a parent component")
}
return syncStatus
}

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { ContestRank } from "utils/types"
interface Props {
rank: ContestRank
}
const props = defineProps<Props>()
const router = useRouter()
function goto() {
router.push({
name: "contest submissions",
query: { username: props.rank.user.username },
})
}
</script>
<template>
{{ rank.accepted_number }} /
<n-button text type="primary" @click="goto">
{{ rank.submission_number }}
</n-button>
</template>
<style scoped></style>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useContestStore } from "oj/store/contest"
import { parseTime } from "utils/functions"
import ContestType from "shared/components/ContestType.vue"
const contestStore = useContestStore()
</script>
<template>
<n-popover
v-if="contestStore.contest"
placement="bottom-end"
:show-arrow="false"
>
<template #trigger>
<n-button>
<template #icon>
<Icon icon="streamline-emojis:exclamation-mark"></Icon>
</template>
比赛信息
</n-button>
</template>
<div v-html="contestStore.contest.description"></div>
<n-descriptions bordered label-placement="left" :column="1">
<n-descriptions-item label="开始时间">
{{
parseTime(contestStore.contest.start_time, "YYYY年M月D日 HH:mm:ss")
}}
</n-descriptions-item>
<n-descriptions-item label="结束时间">
{{ parseTime(contestStore.contest.end_time, "YYYY年M月D日 HH:mm:ss") }}
</n-descriptions-item>
<n-descriptions-item label="比赛类型">
<ContestType :contest="contestStore.contest" />
</n-descriptions-item>
<n-descriptions-item label="发起人">
{{ contestStore.contest.created_by.username }}
</n-descriptions-item>
</n-descriptions>
</n-popover>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { useContestStore } from "oj/store/contest"
import { useBreakpoints } from "shared/composables/breakpoints"
import { ContestStatus } from "utils/constants"
const route = useRoute()
const router = useRouter()
const contestStore = useContestStore()
const { isDesktop } = useBreakpoints()
const contestMenuVisible = computed(() => {
if (contestStore.isContestAdmin) return true
if (!contestStore.isPrivate) {
return contestStore.contestStatus !== ContestStatus.not_started
}
return contestStore.access
})
function goto(name: string) {
router.push({ name: "contest " + name })
}
function getCurrentType(name: string): "primary" | "default" {
if (route.name === "contest " + name) return "primary"
return "default"
}
const options: DropdownOption[] = [
{ label: "比赛题目", key: "problems" },
{ label: "提交信息", key: "submissions" },
{ label: "比赛排名", key: "rank" },
]
</script>
<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>
<n-button :type="getCurrentType('rank')" @click="goto('rank')">
比赛排名
</n-button>
</n-flex>
<n-dropdown v-else :options="options" @select="goto">
<n-button>菜单</n-button>
</n-dropdown>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,231 @@
<template>
<div class="chart" v-if="showChart">
<Line :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup lang="ts">
import { Line } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
} from "chart.js"
import type { ContestRank } from "utils/types"
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
)
interface Props {
ranks: ContestRank[]
problems: Array<{ id: number; title: string }>
}
const props = defineProps<Props>()
const PENALTY_SECONDS = 20 * 60
const showChart = computed(() => {
const hasRanks = props.ranks.length > 0
const hasProblems = props.problems.length >= 3
return hasProblems && hasRanks
})
const colorPalette = [
"#3B82F6",
"#EF4444",
"#10B981",
"#F59E0B",
"#8B5CF6",
"#EC4899",
"#06B6D4",
"#84CC16",
"#F97316",
"#6366F1",
]
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) return `${h}h${m}m`
return `${m}m`
}
interface AcEvent {
time: number
userIndex: number
problemId: string
}
const chartData = computed(() => {
if (!props.ranks || props.ranks.length === 0) {
return { labels: [], datasets: [] }
}
const topUsers = props.ranks.slice(0, 10)
// 收集所有AC事件并按时间排序
const events: AcEvent[] = []
topUsers.forEach((rank, userIndex) => {
Object.entries(rank.submission_info).forEach(([problemId, info]) => {
if (info.is_ac) {
events.push({ time: info.ac_time, userIndex, problemId })
}
})
})
events.sort((a, b) => a.time - b.time)
if (events.length === 0) {
return { labels: [], datasets: [] }
}
// 在每个时间点计算所有人的排名
// 状态: 每个用户当前已AC题数和罚时
const userState = topUsers.map(() => ({
solved: 0,
penalty: 0,
}))
// 用于记录每个用户每道题的错误次数
const userErrors: Map<string, number>[] = topUsers.map(() => new Map())
topUsers.forEach((rank, i) => {
Object.entries(rank.submission_info).forEach(([problemId, info]) => {
if (info.error_number > 0) {
userErrors[i].set(problemId, info.error_number)
}
})
})
function calcRanks(): number[] {
const indexed = userState.map((s, i) => ({ ...s, i }))
indexed.sort((a, b) => {
if (b.solved !== a.solved) return b.solved - a.solved
return a.penalty - b.penalty
})
const ranks = new Array(topUsers.length).fill(0)
indexed.forEach((item, pos) => {
ranks[item.i] = pos + 1
})
return ranks
}
// 时间轴上的数据点: [时间标签, 各用户排名]
const timePoints: number[] = [0]
const rankSnapshots: number[][] = [calcRanks()]
// 按时间处理事件(合并同一时刻的事件)
let i = 0
while (i < events.length) {
const currentTime = events[i].time
// 处理同一时刻的所有事件
while (i < events.length && events[i].time === currentTime) {
const ev = events[i]
userState[ev.userIndex].solved++
const errors = userErrors[ev.userIndex].get(ev.problemId) || 0
userState[ev.userIndex].penalty =
userState[ev.userIndex].penalty + ev.time + errors * PENALTY_SECONDS
i++
}
timePoints.push(currentTime)
rankSnapshots.push(calcRanks())
}
const labels = timePoints.map((t) => formatTime(t))
const datasets = topUsers.map((rank, userIndex) => {
const color = colorPalette[userIndex % colorPalette.length]
const finalRank = rankSnapshots[rankSnapshots.length - 1][userIndex]
return {
label: `#${finalRank} ${rank.user.username}`,
data: rankSnapshots.map((snapshot) => snapshot[userIndex]),
borderColor: color,
backgroundColor: color,
tension: 0.3,
fill: false,
pointRadius: 3,
pointHoverRadius: 6,
pointBackgroundColor: color,
pointBorderColor: "#fff",
pointBorderWidth: 1,
borderWidth: 2.5,
}
})
return { labels, datasets }
})
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index" as const,
intersect: false,
},
plugins: {
legend: {
display: true,
position: "top" as const,
maxHeight: 80,
labels: {
boxWidth: 14,
boxHeight: 3,
padding: 10,
font: { size: 12 },
},
},
tooltip: {
mode: "index" as const,
intersect: false,
itemSort: (a: any, b: any) => a.parsed.y - b.parsed.y,
callbacks: {
title: (context: any) => `比赛进行: ${context[0].label}`,
label: (context: any) => {
const rank = context.parsed.y
const name = context.dataset.label
return `${rank}名 — ${name}`
},
},
},
},
scales: {
x: {
title: {
display: true,
text: "比赛时间",
},
},
y: {
title: {
display: true,
text: "排名",
},
reverse: true,
min: 1,
max: 10,
ticks: {
stepSize: 1,
callback: (value: any) => `${value}`,
},
},
},
}))
</script>
<style scoped>
.chart {
height: 420px;
width: 100%;
margin-bottom: 24px;
}
</style>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { CONTEST_STATUS, ContestStatus } from "utils/constants"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useContestStore } from "../store/contest"
import ContestInfo from "./components/ContestInfo.vue"
import ContestMenu from "./components/ContestMenu.vue"
const props = defineProps<{
contestID: string
}>()
const contestStore = useContestStore()
const message = useMessage()
const { isDesktop } = useBreakpoints()
const password = ref("")
async function check() {
await contestStore.checkPassword(props.contestID, password.value)
if (!contestStore.access) {
message.error("密码错误")
}
}
watch(
() => contestStore.contestStatus,
(nv, ov) => {
if (nv === ContestStatus.underway && ov == ContestStatus.not_started) {
contestStore.init(props.contestID)
}
},
)
onMounted(() => {
contestStore.init(props.contestID)
})
onBeforeUnmount(contestStore.clear)
const passwordFormVisible = computed(
() =>
contestStore.isPrivate &&
!contestStore.access &&
!contestStore.isContestAdmin,
)
</script>
<template>
<n-flex vertical size="large" v-if="contestStore.contest">
<n-flex align="center" justify="space-between">
<n-flex align="center">
<n-tag :type="CONTEST_STATUS[contestStore.contestStatus]['type']">
{{ contestStore.countdown }}
</n-tag>
<Icon
v-if="contestStore.isPrivate"
icon="streamline-ultimate-color:shield-lock"
:height="30"
></Icon>
<h2 class="contestTitle">{{ contestStore.contest.title }}</h2>
</n-flex>
<n-flex align="center">
<ContestInfo />
<ContestMenu />
</n-flex>
</n-flex>
<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-form-item>
<n-form-item>
<n-button @click="check" :disabled="!password">确认</n-button>
</n-form-item>
</n-form>
<router-view></router-view>
</n-flex>
</template>
<style scoped>
.contestTitle {
font-weight: 500;
margin: 0;
}
</style>

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
import { useRouteQuery } from "@vueuse/router"
import { NTag } from "naive-ui"
import { getContestList } from "oj/api"
import { duration, parseTime } from "utils/functions"
import type { Contest } from "utils/types"
import ContestTitle from "shared/components/ContestTitle.vue"
import Pagination from "shared/components/Pagination.vue"
import { useAuthModalStore } from "shared/store/authModal"
import { usePagination } from "shared/composables/pagination"
import { useUserStore } from "shared/store/user"
import { CONTEST_STATUS, ContestType } from "utils/constants"
import { renderTableTitle } from "utils/renders"
const router = useRouter()
const userStore = useUserStore()
const authStore = useAuthModalStore()
interface ContestQuery {
keyword: string
status: string
tag: string
}
// 使用分页 composable
const { query, clearQuery } = usePagination<ContestQuery>({
keyword: useRouteQuery("keyword", "").value,
status: useRouteQuery("status", "").value,
tag: useRouteQuery("tag", "").value,
})
const data = ref<Contest[]>([])
const total = ref(0)
const options: SelectOption[] = [
{ label: "全部", value: "" },
{ label: "未开始", value: "1" },
{ label: "进行中", value: "0" },
{ label: "已结束", value: "-1" },
]
const tags: SelectOption[] = [
{ label: "全部", value: "" },
{ label: "练习", value: "练习" },
{ label: "期中", value: "期中" },
{ label: "期末", value: "期末" },
]
const columns: DataTableColumn<Contest>[] = [
{
title: renderTableTitle("状态", "streamline-emojis:collision"),
key: "status",
width: 100,
render: (row) =>
h(
NTag,
{ type: CONTEST_STATUS[row.status]["type"] },
() => CONTEST_STATUS[row.status]["name"],
),
},
{
title: renderTableTitle("比赛", "streamline-emojis:bouquet"),
key: "title",
minWidth: 360,
render: (row) => h(ContestTitle, { contest: row }),
},
{
title: renderTableTitle("标签", "fluent-emoji-flat:keycap-hashtag"),
key: "tag",
width: 100,
render: (row) => h(NTag, () => row.tag),
},
{
title: renderTableTitle("开始时间", "fluent-emoji-flat:eleven-thirty"),
key: "start_time",
width: 180,
render: (row) => parseTime(row.start_time),
},
{
title: renderTableTitle("比赛时长", "streamline-emojis:fishing-pole"),
key: "duration",
width: 180,
render: (row) => duration(row.start_time, row.end_time),
},
]
async function listContests() {
const offset = (query.page - 1) * query.limit
const res = await getContestList({
offset,
limit: query.limit,
keyword: query.keyword,
status: query.status,
tag: query.tag,
})
data.value = res.data.results
total.value = res.data.total
}
function search(value: string) {
query.keyword = value
}
function clear() {
clearQuery()
}
onMounted(listContests)
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listContests, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(() => [query.page, query.limit, query.status, query.tag], listContests)
function rowProps(row: Contest) {
return {
style: "cursor: pointer",
onClick() {
if (!userStore.isAuthed && row.contest_type === ContestType.private) {
authStore.openLoginModal()
} else {
router.push("/contest/" + row.id)
}
},
}
}
</script>
<template>
<n-flex vertical size="large">
<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-form-item>
<n-form-item label="标签">
<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>
<n-form-item>
<n-input
style="width: 180px"
clearable
v-model:value="query.keyword"
placeholder="比赛标题"
/>
</n-form-item>
<n-form-item>
<n-flex :wrap="false">
<n-button @click="search(query.keyword)">搜索</n-button>
<n-button @click="clear" quaternary>重置</n-button>
</n-flex>
</n-form-item>
</n-form>
</n-space>
<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"
/>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { ProblemFiltered } from "utils/types"
import ProblemStatus from "oj/problem/components/ProblemStatus.vue"
import { useContestStore } from "oj/store/contest"
import { renderTableTitle } from "utils/renders"
const props = defineProps<{ contestID: string }>()
const router = useRouter()
const contestStore = useContestStore()
const problemsColumns: DataTableColumn<ProblemFiltered>[] = [
{
title: renderTableTitle("状态", "streamline-ultimate-color:music-note-1"),
key: "status",
width: 100,
render: (row) => h(ProblemStatus, { status: row.status }),
},
{
title: renderTableTitle("编号", "fluent-emoji-flat:input-numbers"),
key: "_id",
width: 100,
},
{
title: renderTableTitle("题目", "streamline-emojis:rice-ball"),
key: "title",
minWidth: 200,
},
{
title: renderTableTitle("提交数", "streamline-emojis:clinking-beer-mugs"),
key: "submission",
align: "center",
width: 120,
},
{
title: renderTableTitle("通过率", "streamline-emojis:clapping-hands-1"),
key: "rate",
align: "center",
width: 120,
},
]
function rowProps(row: ProblemFiltered) {
return {
style: "cursor: pointer",
onClick() {
router.push(`/contest/${props.contestID}/problem/${row._id}`)
},
}
}
</script>
<template>
<n-data-table
striped
:data="contestStore.problems"
:columns="problemsColumns"
:row-props="rowProps"
/>
</template>
<style scoped></style>

View File

@@ -0,0 +1,341 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { NButton, useThemeVars } from "naive-ui"
import { getContestProblems, getContestRank } from "oj/api"
import { secondsToDuration } from "utils/functions"
import { useContestStore } from "oj/store/contest"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { ContestStatus } from "utils/constants"
import { renderTableTitle } from "utils/renders"
import type { ContestRank, ProblemFiltered } from "utils/types"
import AcAndSubmission from "../components/AcAndSubmission.vue"
import LineChart from "../components/LineChart.vue"
interface Props {
contestID: string
}
const props = defineProps<Props>()
const route = useRoute()
const router = useRouter()
const theme = useThemeVars()
const contestStore = useContestStore()
const total = ref(0)
const data = ref<ContestRank[]>([])
const chart = ref<ContestRank[]>([])
const problems = ref<ProblemFiltered[]>([])
const [autoRefresh] = useToggle(true)
const { resume, pause } = useIntervalFn(
() => {
query.page = 1
listRanks()
},
10000,
{
immediate: false,
},
)
// 使用分页 composable
const { query } = usePagination({}, { defaultLimit: 50 })
const columns = ref<DataTableColumn<ContestRank>[]>([
{
title: renderTableTitle("编号", "fluent-emoji-flat:input-numbers"),
key: "id",
width: 80,
fixed: "left",
align: "center",
render: (_, index) => index + (query.page - 1) * query.limit + 1,
},
{
title: renderTableTitle(
"用户",
"streamline-emojis:smiling-face-with-sunglasses",
),
key: "username",
width: 120,
fixed: "left",
align: "center",
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.user.username),
},
() => row.user.username,
),
},
{
title: renderTableTitle(
"正确数/总提交",
"streamline-ultimate-color:color-palette",
),
key: "submission",
width: 140,
align: "center",
render: (row) => h(AcAndSubmission, { rank: row }),
},
{
title: "总时间",
key: "total_time",
width: 120,
align: "center",
render: (row) => secondsToDuration(row.total_time),
},
])
async function listRanks() {
const res = await getContestRank(props.contestID, {
limit: query.limit,
offset: query.limit * (query.page - 1),
})
total.value = res.data.total
data.value = res.data.results
if (query.page === 1) {
chart.value = data.value
}
}
async function addColumns() {
try {
problems.value = await getContestProblems(props.contestID)
problems.value.map((problem) => {
columns.value.push({
align: "center",
title: () =>
h(
NButton,
{
text: true,
type: "primary",
onClick: () => {
const data = router.resolve({
name: "contest problem",
params: {
contestID: route.params.contestID,
problemID: problem._id,
},
})
window.open(data.href, "_blank")
},
},
() => problem.title,
),
render: (row) => {
if (row.submission_info[problem.id]) {
const status = row.submission_info[problem.id]
let acTime
let errorNumber
if (status.is_ac) {
acTime = h("span", secondsToDuration(status.ac_time))
}
if (status.is_first_ac) {
acTime = [
h(Icon, {
icon: "fluent-emoji:1st-place-medal",
height: 20,
width: 20,
}),
h("span", secondsToDuration(status.ac_time)),
]
}
if (status.error_number) {
errorNumber = h(
"span",
{ style: "margin: 0" },
`(-${status.error_number})`,
)
}
return h("div", { class: "oj-time-with-modal" }, [
acTime,
errorNumber,
])
}
},
cellProps: (row) => {
let backgroundColor = ""
let color = theme.value.textColorBase
if (row.submission_info[problem.id]) {
const status = row.submission_info[problem.id]
if (status.is_first_ac) {
backgroundColor = theme.value.primaryColor
color = theme.value.baseColor
} else if (status.is_ac) {
const success = theme.value.successColor
backgroundColor = success + "50"
color = theme.value.textColorBase
} else {
const error = theme.value.errorColor
backgroundColor = error + "50"
color = theme.value.textColorBase
}
}
return { style: { backgroundColor, color } }
},
key: problem.id,
width: 150,
ellipsis: true,
})
})
} catch (err) {
problems.value = []
}
}
// 导出弹窗
const showExportModal = ref(false)
const exportLoading = ref(false)
const exportForm = reactive({
first: 0,
second: 0,
third: 0,
})
watch(
() => total.value,
(val) => {
if (val > 0) {
exportForm.first = Math.round(val * 0.1)
exportForm.second = Math.round(val * 0.2)
exportForm.third = Math.round(val * 0.3)
}
},
)
function openExportModal() {
if (total.value > 0) {
exportForm.first = Math.round(total.value * 0.1)
exportForm.second = Math.round(total.value * 0.2)
exportForm.third = Math.round(total.value * 0.3)
}
showExportModal.value = true
}
async function downloadExcel() {
exportLoading.value = true
try {
const res = await getContestRank(props.contestID, {
limit: total.value || 10000,
offset: 0,
})
const allRanks: ContestRank[] = res.data.results
const rows = allRanks.map((rank, index) => {
const rank1 = index + 1
let level = ""
if (rank1 <= exportForm.first) {
level = "一等奖"
} else if (rank1 <= exportForm.first + exportForm.second) {
level = "二等奖"
} else if (
rank1 <=
exportForm.first + exportForm.second + exportForm.third
) {
level = "三等奖"
} else {
level = "参与奖"
}
return { 用户名: rank.user.username, 等级: level }
})
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")
a.href = url
a.download = `${contestStore.contest?.title ?? "contest"}获奖情况.csv`
a.click()
URL.revokeObjectURL(url)
showExportModal.value = false
} finally {
exportLoading.value = false
}
}
// 监听分页参数变化
watch([() => query.page, () => query.limit], listRanks)
watch(autoRefresh, (checked) => (checked ? resume() : pause()))
onMounted(() => {
listRanks()
addColumns()
})
</script>
<template>
<!-- 排名变化图表 -->
<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-space justify="end" align="center">
<n-form
label-placement="left"
inline
:show-feedback="false"
v-if="contestStore.contestStatus === ContestStatus.underway"
>
<n-form-item label="开启自动刷新">
<n-switch v-model:value="autoRefresh" />
</n-form-item>
</n-form>
<n-button
v-if="contestStore.contestStatus === ContestStatus.finished"
type="primary"
@click="openExportModal"
>
导出数据
</n-button>
<Pagination
:total="total"
:limit="query.limit"
:page="query.page"
@update:limit="(limit: number) => (query.limit = limit)"
@update:page="(page: number) => (query.page = page)"
/>
</n-space>
<n-modal v-model:show="showExportModal" preset="dialog" title="导出获奖数据">
<n-form
label-placement="left"
label-width="auto"
:show-feedback="false"
style="margin-top: 16px"
>
<n-form-item label="一等奖人数" style="margin-bottom: 12px">
<n-input-number v-model:value="exportForm.first" :min="0" />
</n-form-item>
<n-form-item label="二等奖人数" style="margin-bottom: 12px">
<n-input-number v-model:value="exportForm.second" :min="0" />
</n-form-item>
<n-form-item label="三等奖人数">
<n-input-number v-model:value="exportForm.third" :min="0" />
</n-form-item>
</n-form>
<template #action>
<n-button @click="showExportModal = false">取消</n-button>
<n-button type="primary" :loading="exportLoading" @click="downloadExcel">
下载 CSV
</n-button>
</template>
</n-modal>
</template>
<style>
.oj-time-with-modal {
display: flex;
}
</style>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup>
import FlowchartEditor from "shared/components/FlowchartEditor/index.vue"
</script>
<template>
<FlowchartEditor />
</template>
<style scoped></style>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import type { Exercise, ExerciseDebugData } from "utils/types"
import { highlightLines } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseDebugData)
const lineHtml = computed(() => highlightLines(data.value.lines, props.lang))
const selected = ref<Set<number>>(new Set())
const submitted = ref(false)
watch(() => props.exercise.id, reset, { immediate: true })
const allCorrect = computed(() => {
const ans = new Set(data.value.answer)
if (selected.value.size !== ans.size) return false
for (const i of selected.value) if (!ans.has(i)) return false
return true
})
const locked = computed(() => submitted.value && allCorrect.value)
function toggle(i: number) {
if (locked.value) return
submitted.value = false
const s = new Set(selected.value)
if (s.has(i)) s.delete(i)
else s.add(i)
selected.value = s
}
function lineStatus(i: number): "correct" | "wrong" | "selected" | "default" {
if (!submitted.value) return selected.value.has(i) ? "selected" : "default"
const isAns = data.value.answer.includes(i)
const isSel = selected.value.has(i)
if (isAns) return isSel ? "correct" : "wrong" // 漏选也标红
if (isSel) return "wrong"
return "default"
}
function lineStyle(i: number): Record<string, string> {
const status = lineStatus(i)
const color =
status === "correct"
? "#18a058"
: status === "wrong"
? "#d03050"
: status === "selected"
? "#2080f0"
: "var(--n-border-color)"
const plain = color === "var(--n-border-color)"
return {
display: "flex",
alignItems: "center",
gap: "10px",
padding: "6px 12px",
borderRadius: "6px",
border: `1.5px solid ${color}`,
background: plain ? "transparent" : color + "14",
cursor: locked.value ? "default" : "pointer",
fontFamily: "Monaco",
userSelect: "none",
}
}
function submit() {
submitted.value = true
}
function reset() {
selected.value = new Set()
submitted.value = false
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="info" :bordered="false">练一练 · 代码找错</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
<p style="color: var(--n-text-color-3); font-size: 13px; margin: 0 0 12px">
点击你认为有错误的代码行可多选
</p>
<n-space vertical :size="6">
<div
v-for="(line, idx) in data.lines"
:key="idx"
:style="lineStyle(idx)"
@click="toggle(idx)"
>
<span
style="color: #bbb; width: 22px; text-align: right; flex-shrink: 0"
>
{{ idx + 1 }}
</span>
<span v-html="lineHtml[idx]" style="white-space: pre" />
</div>
</n-space>
<n-alert
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '找对了!' : '还没找全,红色行是错误所在'"
style="margin-top: 12px"
>
<template v-if="submitted && data.explanation" #default>
{{ data.explanation }}
</template>
</n-alert>
<n-space style="margin-top: 12px" :size="8">
<n-button type="info" :disabled="locked" @click="submit">提交</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,135 @@
<script setup lang="ts">
import type { Exercise, ExerciseFillData } from "utils/types"
import { highlight } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseFillData)
type CodeSeg = { type: "code"; html: string }
type BlankSeg = { type: "blank"; answers: string[]; index: number }
type Segment = CodeSeg | BlankSeg
const segments = computed<Segment[]>(() => {
const blanks: string[][] = []
const markedCode = data.value.code.replace(/\{\{([^}]+)\}\}/g, (_, inner) => {
blanks.push(inner.split("|"))
return `____${blanks.length - 1}____`
})
const highlighted = highlight(markedCode, props.lang)
const parts = highlighted.split(/____(\d+)____/)
const result: Segment[] = []
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) {
if (parts[i]) result.push({ type: "code", html: parts[i] })
} else {
const idx = parseInt(parts[i])
result.push({ type: "blank", answers: blanks[idx], index: idx })
}
}
return result
})
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)
watch(() => props.exercise.id, reset, { immediate: true })
function reset() {
userInputs.value = Array(blankCount.value).fill("")
wrongBlanks.value = new Set()
allCorrect.value = false
}
function submit() {
if (allCorrect.value) return
const wrong = new Set<number>()
for (const seg of segments.value) {
if (seg.type !== "blank") continue
if (!seg.answers.includes(userInputs.value[seg.index]?.trim() ?? "")) {
wrong.add(seg.index)
}
}
wrongBlanks.value = wrong
allCorrect.value = wrong.size === 0
}
function inputWidth(idx: number): string {
return Math.max(4, (userInputs.value[idx]?.length ?? 0) + 2) + "ch"
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="warning" :bordered="false">练一练 · 代码填空</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
<pre
:style="{
fontFamily: 'Monaco',
fontSize: '16px',
lineHeight: '1.6',
background: 'var(--n-color)',
border: '1px solid var(--n-border-color)',
borderRadius: '6px',
padding: '12px',
overflowX: 'auto',
whiteSpace: 'pre-wrap',
margin: 0,
}"
><template v-for="(seg, i) in segments" :key="i"
><span v-if="seg.type === 'code'" v-html="seg.html" /><input
v-else
:value="userInputs[seg.index]"
:disabled="allCorrect"
:style="{
width: inputWidth(seg.index),
fontFamily: 'Monaco',
fontSize: '16px',
padding: '2px 6px',
borderRadius: '3px',
border: `1.5px solid ${
allCorrect
? '#18a058'
: wrongBlanks.has(seg.index)
? '#d03050'
: 'var(--n-border-color)'
}`,
background: allCorrect
? 'rgba(24,160,88,0.08)'
: wrongBlanks.has(seg.index)
? 'rgba(208,48,80,0.07)'
: 'transparent',
outline: 'none',
color: 'inherit',
minWidth: '4ch',
}"
@input="userInputs[seg.index] = ($event.target as HTMLInputElement).value"
/></template></pre>
<n-alert
v-if="wrongBlanks.size > 0 || allCorrect"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '全部正确!' : '有填写错误,请检查红色标注的空位'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="warning" :disabled="allCorrect" @click="submit">
提交
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import type { Exercise, ExerciseGroupData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseGroupData)
const order = ref<number[]>([]) // item 的稳定展示顺序(初始乱序)
const placement = ref<number[]>([]) // placement[itemIdx] = 桶下标,-1 表示在池中
const dragIdx = ref<number | null>(null)
const submitted = ref(false)
function init() {
order.value = shuffle(data.value.items.map((_, i) => i))
placement.value = Array(data.value.items.length).fill(-1)
dragIdx.value = null
submitted.value = false
}
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 locked = computed(() => submitted.value && allCorrect.value)
function onDragStart(i: number) {
if (locked.value) return
dragIdx.value = i
}
function dropTo(bucket: number) {
if (locked.value || dragIdx.value === null) return
placement.value[dragIdx.value] = bucket
dragIdx.value = null
submitted.value = false
}
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)
}
function itemStatus(i: number): "correct" | "wrong" | "default" {
if (!submitted.value || placement.value[i] === -1) return "default"
return placement.value[i] === data.value.answer[i] ? "correct" : "wrong"
}
function chipStyle(i: number): Record<string, string> {
const status = itemStatus(i)
const color =
status === "correct"
? "#18a058"
: status === "wrong"
? "#d03050"
: "var(--n-border-color)"
const plain = color === "var(--n-border-color)"
return {
padding: "6px 12px",
borderRadius: "6px",
border: `1.5px solid ${color}`,
background: plain ? "var(--n-color)" : color + "14",
cursor: locked.value ? "default" : "grab",
userSelect: "none",
fontSize: "15px",
}
}
function submit() {
submitted.value = true
}
function reset() {
init()
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="warning" :bordered="false">练一练 · 归类分组</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
<p style="color: var(--n-text-color-3); font-size: 13px; margin: 0 0 12px">
把下面的项目拖到对应的分组里可在分组间拖动调整
</p>
<div
:style="{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
minHeight: '48px',
padding: '10px',
border: '1.5px dashed var(--n-border-color)',
borderRadius: '8px',
marginBottom: '14px',
}"
@dragover.prevent
@drop="dropTo(-1)"
>
<span
v-if="poolItems.length === 0"
style="color: var(--n-text-color-3); font-size: 13px"
>
已全部归类
</span>
<div
v-for="i in poolItems"
:key="i"
draggable="true"
:style="chipStyle(i)"
@dragstart="onDragStart(i)"
>
{{ data.items[i] }}
</div>
</div>
<div
:style="{
display: 'grid',
gridTemplateColumns: `repeat(${data.buckets.length}, 1fr)`,
gap: '12px',
}"
>
<div
v-for="(bucket, b) in data.buckets"
:key="b"
:style="{
minHeight: '88px',
padding: '10px',
border: '1.5px solid var(--n-border-color)',
borderRadius: '8px',
}"
@dragover.prevent
@drop="dropTo(b)"
>
<p
style="
font-weight: 600;
margin: 0 0 8px;
text-align: center;
font-size: 14px;
"
>
{{ bucket }}
</p>
<n-space :size="8">
<div
v-for="i in itemsIn(b)"
:key="i"
draggable="true"
:style="chipStyle(i)"
@dragstart="onDragStart(i)"
>
{{ data.items[i] }}
</div>
</n-space>
</div>
</div>
<n-alert
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '归类全部正确!' : '有归类错误,红色项需要调整'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="warning" :disabled="!allPlaced || locked" @click="submit">
提交
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import type { Exercise, ExerciseMatchData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseMatchData)
const PALETTE = [
"#2080f0",
"#18a058",
"#f0a020",
"#d03050",
"#8a2be2",
"#0891b2",
"#db2777",
"#65a30d",
]
const rightOrder = ref<number[]>([]) // 显示顺序里的 right 原始下标
const pairs = ref<(number | null)[]>([]) // pairs[leftIdx] = 配对的 right 原始下标
const selectedLeft = ref<number | null>(null)
const submitted = ref(false)
function init() {
const n = data.value.right.length
rightOrder.value = shuffle(Array.from({ length: n }, (_, i) => i))
pairs.value = Array(data.value.left.length).fill(null)
selectedLeft.value = null
submitted.value = false
}
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 locked = computed(() => submitted.value && allCorrect.value)
function leftOf(rightIdx: number): number {
return pairs.value.findIndex((p) => p === rightIdx)
}
function onLeftClick(i: number) {
if (locked.value) return
submitted.value = false
if (pairs.value[i] !== null) {
pairs.value[i] = null
selectedLeft.value = i
return
}
selectedLeft.value = selectedLeft.value === i ? null : i
}
function onRightClick(rightIdx: number) {
if (locked.value) return
submitted.value = false
if (selectedLeft.value === null) {
const l = leftOf(rightIdx)
if (l !== -1) pairs.value[l] = null
return
}
const prev = leftOf(rightIdx)
if (prev !== -1) pairs.value[prev] = null
pairs.value[selectedLeft.value] = rightIdx
selectedLeft.value = null
}
function submit() {
submitted.value = true
}
function reset() {
init()
}
function leftColor(i: number): string {
if (submitted.value) {
if (pairs.value[i] === null) return "#d03050"
return pairs.value[i] === data.value.answer[i] ? "#18a058" : "#d03050"
}
if (selectedLeft.value === i) return "#2080f0"
if (pairs.value[i] !== null) return PALETTE[i % PALETTE.length]
return "var(--n-border-color)"
}
function rightColor(rightIdx: number): string {
const l = leftOf(rightIdx)
if (submitted.value) {
if (l === -1) return "var(--n-border-color)"
return pairs.value[l] === data.value.answer[l] ? "#18a058" : "#d03050"
}
if (l === -1) return "var(--n-border-color)"
return PALETTE[l % PALETTE.length]
}
function itemStyle(color: string, selected: boolean): Record<string, string> {
const plain = color === "var(--n-border-color)"
return {
display: "flex",
alignItems: "center",
gap: "8px",
padding: "10px 12px",
borderRadius: "6px",
border: `${selected ? "2px" : "1.5px"} solid ${color}`,
background: plain ? "transparent" : color + "14",
cursor: locked.value ? "default" : "pointer",
userSelect: "none",
fontSize: "15px",
}
}
function dotStyle(color: string): Record<string, string> {
return {
width: "10px",
height: "10px",
borderRadius: "50%",
background: color,
flexShrink: "0",
}
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="primary" :bordered="false">练一练 · 连线匹配</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
<p style="color: var(--n-text-color-3); font-size: 13px; margin: 0 0 12px">
先点左边一项再点右边一项即可连线点击已连线的项可取消
</p>
<div style="display: flex; gap: 24px; align-items: flex-start">
<n-space vertical :size="8" style="flex: 1">
<div
v-for="(item, i) in data.left"
:key="'l' + i"
:style="itemStyle(leftColor(i), selectedLeft === i)"
@click="onLeftClick(i)"
>
<span
v-if="pairs[i] !== null && !submitted"
:style="dotStyle(PALETTE[i % PALETTE.length])"
/>
<span>{{ item }}</span>
</div>
</n-space>
<n-space vertical :size="8" style="flex: 1">
<div
v-for="rightIdx in rightOrder"
:key="'r' + rightIdx"
:style="itemStyle(rightColor(rightIdx), false)"
@click="onRightClick(rightIdx)"
>
<span
v-if="leftOf(rightIdx) !== -1 && !submitted"
:style="dotStyle(PALETTE[leftOf(rightIdx) % PALETTE.length])"
/>
<span>{{ data.right[rightIdx] }}</span>
</div>
</n-space>
</div>
<n-alert
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '全部匹配正确!' : '有匹配错误,红色项需要重新连线'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="primary" :disabled="!allPaired || locked" @click="submit">
提交
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import type { Exercise, ExerciseMcqData } from "utils/types"
const props = defineProps<{ exercise: Exercise }>()
const data = computed(() => props.exercise.data as ExerciseMcqData)
const isSingle = computed(() => data.value.answer.length === 1)
const selected = ref<Set<number>>(new Set())
const correct = ref(false)
const wrong = ref(false)
const partial = ref(false)
function select(idx: number) {
if (correct.value) return
const s = new Set(selected.value)
if (isSingle.value) {
s.clear()
if (!selected.value.has(idx)) s.add(idx)
} else {
if (s.has(idx)) s.delete(idx)
else s.add(idx)
}
selected.value = s
wrong.value = false
partial.value = false
}
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))
if (isEqual) {
correct.value = true
wrong.value = false
partial.value = false
} else {
selected.value = new Set()
const hasIntersection = [...sel].some((v) => answer.has(v))
if (hasIntersection) {
partial.value = true
wrong.value = false
} else {
wrong.value = true
partial.value = false
}
}
}
function reset() {
selected.value = new Set()
correct.value = false
wrong.value = false
partial.value = false
}
function optionType(idx: number): "default" | "primary" | "success" {
if (correct.value && data.value.answer.includes(idx)) return "success"
if (selected.value.has(idx)) return "primary"
return "default"
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-space align="center" :size="8">
<n-tag type="success" :bordered="false">
练一练 · {{ isSingle ? "单选题" : "多选题" }}
</n-tag>
</n-space>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
<n-space vertical :size="8">
<n-button
v-for="(opt, idx) in data.options"
:key="idx"
:type="optionType(idx)"
:secondary="optionType(idx) !== 'default'"
:tertiary="optionType(idx) === 'default'"
:strong="selected.has(idx)"
:style="{
justifyContent: 'flex-start',
width: '100%',
textAlign: 'left',
}"
@click="select(idx)"
>
<template #icon>
<span style="font-weight: 700">{{
String.fromCharCode(65 + idx)
}}</span>
</template>
{{ opt }}
</n-button>
</n-space>
<n-alert
v-if="correct || wrong || partial"
:type="correct ? 'success' : partial ? 'warning' : 'error'"
: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>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import type { Exercise, ExercisePredictData } from "utils/types"
import { highlight } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExercisePredictData)
const codeHtml = computed(() => highlight(data.value.code, props.lang))
const userInput = ref("")
const submitted = ref(false)
watch(() => props.exercise.id, reset, { immediate: true })
function normalize(s: string): string {
return s
.replace(/\r\n/g, "\n")
.split("\n")
.map((l) => l.replace(/\s+$/, ""))
.join("\n")
.replace(/^\n+/, "")
.replace(/\n+$/, "")
}
const allCorrect = computed(() =>
data.value.answer.some((a) => normalize(a) === normalize(userInput.value)),
)
function submit() {
submitted.value = true
}
function reset() {
userInput.value = ""
submitted.value = false
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="error" :bordered="false">练一练 · 输出预测</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
<pre
:style="{
fontFamily: 'Monaco',
fontSize: '16px',
lineHeight: '1.6',
background: 'var(--n-color)',
border: '1px solid var(--n-border-color)',
borderRadius: '6px',
padding: '12px',
overflowX: 'auto',
margin: 0,
}"
><code v-html="codeHtml" /></pre>
<p style="font-weight: 500; margin: 14px 0 8px">这段代码会输出什么</p>
<n-input
v-model:value="userInput"
type="textarea"
:rows="3"
:disabled="submitted && allCorrect"
placeholder="在这里输入程序会打印的内容"
style="font-family: Monaco"
/>
<n-alert
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '输出正确!' : '输出不正确,再读读代码看看'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button
type="error"
:disabled="submitted && allCorrect"
@click="submit"
>
提交
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import type { Exercise, ExerciseSortData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
import { highlightLines } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseSortData)
type LineItem = { originalIdx: number; text: string }
const lines = ref<LineItem[]>([])
const submitted = ref(false)
function init() {
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) {
;[shuffled[0], shuffled[1]] = [shuffled[1], shuffled[0]]
}
lines.value = shuffled
submitted.value = false
}
onMounted(init)
watch(() => props.exercise.id, init)
const dragIdx = ref<number | null>(null)
function onDragStart(idx: number) {
dragIdx.value = idx
}
function onDrop(targetIdx: number) {
if (dragIdx.value === null || dragIdx.value === targetIdx) return
const newLines = [...lines.value]
const [moved] = newLines.splice(dragIdx.value, 1)
newLines.splice(targetIdx, 0, moved)
lines.value = newLines
dragIdx.value = null
submitted.value = false
}
function lineStatus(idx: number): "correct" | "wrong" | "default" {
if (!submitted.value) return "default"
return lines.value[idx].originalIdx === idx ? "correct" : "wrong"
}
const allCorrect = computed(() =>
lines.value.every((item, i) => item.originalIdx === i),
)
function submit() {
submitted.value = true
}
function reset() {
init()
}
const lineHtml = computed<string[]>(() =>
highlightLines(data.value.lines, props.lang),
)
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="info" :bordered="false">练一练 · 代码排序</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
<n-space vertical :size="6">
<div
v-for="(line, idx) in lines"
:key="line.originalIdx"
draggable="true"
:style="{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '8px 12px',
borderRadius: '6px',
border: `1.5px ${submitted ? 'solid' : 'dashed'} ${
lineStatus(idx) === 'correct'
? '#18a058'
: lineStatus(idx) === 'wrong'
? '#d03050'
: 'var(--n-border-color)'
}`,
background:
lineStatus(idx) === 'correct'
? 'rgba(24,160,88,0.08)'
: lineStatus(idx) === 'wrong'
? 'rgba(208,48,80,0.07)'
: 'transparent',
cursor: 'grab',
fontFamily: 'Monaco',
userSelect: 'none',
}"
@dragstart="onDragStart(idx)"
@dragover.prevent
@drop="onDrop(idx)"
>
<span style="color: #bbb; cursor: grab"></span>
<span v-html="lineHtml[line.originalIdx]" style="white-space: pre" />
</div>
</n-space>
<n-alert
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '顺序正确!' : '顺序有误,红色行需要调整'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="info" :disabled="submitted && allCorrect" @click="submit">
提交
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import type { Exercise } from "utils/types"
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 ExerciseDebug = defineAsyncComponent(() => import("./ExerciseDebug.vue"))
const ExerciseGroup = defineAsyncComponent(() => import("./ExerciseGroup.vue"))
defineProps<{ exercise: Exercise; lang?: string }>()
</script>
<template>
<ExerciseMcq v-if="exercise.type === 'mcq'" :exercise="exercise" />
<ExerciseSort
v-else-if="exercise.type === 'sort'"
:exercise="exercise"
:lang="lang"
/>
<ExerciseFill
v-else-if="exercise.type === 'fill'"
:exercise="exercise"
:lang="lang"
/>
<ExerciseMatch v-else-if="exercise.type === 'match'" :exercise="exercise" />
<ExercisePredict
v-else-if="exercise.type === 'predict'"
:exercise="exercise"
:lang="lang"
/>
<ExerciseDebug
v-else-if="exercise.type === 'debug'"
:exercise="exercise"
:lang="lang"
/>
<ExerciseGroup v-else-if="exercise.type === 'group'" :exercise="exercise" />
</template>

View File

@@ -0,0 +1,50 @@
/* 练一练代码高亮配色(明 / 暗),由涉及代码高亮的题型组件统一引入 */
.hljs-keyword,
.hljs-operator,
.hljs-selector-tag {
color: #d73a49;
}
.hljs-string,
.hljs-regexp,
.hljs-template-literal {
color: #032f62;
}
.hljs-comment,
.hljs-quote {
color: #6a737d;
font-style: italic;
}
.hljs-number,
.hljs-literal {
color: #005cc5;
}
.hljs-built_in,
.hljs-title.function_,
.hljs-class .hljs-title {
color: #6f42c1;
}
.dark .hljs-keyword,
.dark .hljs-operator,
.dark .hljs-selector-tag {
color: #c678dd;
}
.dark .hljs-string,
.dark .hljs-regexp,
.dark .hljs-template-literal {
color: #98c379;
}
.dark .hljs-comment,
.dark .hljs-quote {
color: #7f848e;
font-style: italic;
}
.dark .hljs-number,
.dark .hljs-literal {
color: #e5c07b;
}
.dark .hljs-built_in,
.dark .hljs-title.function_,
.dark .hljs-class .hljs-title {
color: #61afef;
}

View File

@@ -0,0 +1,41 @@
import hljs from "highlight.js/lib/core"
import python from "highlight.js/lib/languages/python"
import c from "highlight.js/lib/languages/c"
hljs.registerLanguage("python", python)
hljs.registerLanguage("c", c)
export function escapeHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
}
function normalizeLang(lang?: string): "python" | "c" | null {
return lang === "python" ? "python" : lang === "c" ? "c" : null
}
// 把整段代码高亮为 HTML不支持的语言或异常时回退为转义文本
export function highlight(code: string, lang?: string): string {
const language = normalizeLang(lang)
if (language) {
try {
return hljs.highlight(code, { language }).value
} catch {
// fall through
}
}
return escapeHtml(code)
}
// 按行高亮:整体高亮后再按行切分,保证跨行 token 着色正确,返回逐行 HTML 数组
export function highlightLines(lines: string[], lang?: string): string[] {
const language = normalizeLang(lang)
if (language) {
try {
const html = hljs.highlight(lines.join("\n"), { language }).value
return html.split("\n")
} catch {
// fall through
}
}
return lines.map((line) => escapeHtml(line))
}

View File

@@ -0,0 +1,36 @@
import type { Exercise } from "utils/types"
type Segment =
{ type: "md"; content: string } | { type: "exercise"; exercise: Exercise }
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
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = regex.exec(content)) !== null) {
if (match.index > lastIndex) {
segments.push({
type: "md",
content: content.slice(lastIndex, match.index),
})
}
const id = parseInt(match[1])
const exercise = exerciseMap.get(id)
if (exercise) {
segments.push({ type: "exercise", exercise })
}
lastIndex = regex.lastIndex
}
if (lastIndex < content.length) {
segments.push({ type: "md", content: content.slice(lastIndex) })
}
return segments
}

View File

@@ -0,0 +1,9 @@
// FisherYates 洗牌,返回新数组,不修改原数组
export function shuffle<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}

View File

@@ -0,0 +1,255 @@
<template>
<div class="learn-container">
<!-- 桌面端布局 -->
<n-grid
:cols="5"
:x-gap="16"
v-if="tutorial.id && isDesktop"
class="learn-grid"
>
<n-gi :span="1" class="learn-col">
<n-card title="教程目录" :bordered="false" size="small">
<n-list hoverable clickable>
<n-list-item
v-for="(item, index) in titles"
:key="item.id"
@click="goToLesson(index + 1)"
>
<n-text
:type="step === index + 1 ? 'primary' : undefined"
:strong="step === index + 1"
>
{{ index + 1 }}. {{ item.title }}
</n-text>
</n-list-item>
</n-list>
</n-card>
</n-gi>
<n-gi :span="tutorial.code ? 2 : 4" class="learn-col">
<n-card
:title="`第 ${step} 课:${titles[step - 1]?.title}`"
:bordered="false"
size="small"
>
<template v-for="(seg, i) in segments" :key="i">
<MdPreview
v-if="seg.type === 'md'"
preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'"
:model-value="seg.content"
/>
<ExerciseWidget
v-else
:exercise="seg.exercise"
:lang="tutorial.type"
/>
</template>
</n-card>
</n-gi>
<n-gi :span="2" v-if="tutorial.code" class="learn-col learn-col--code">
<n-card
title="示例代码"
:bordered="false"
size="small"
class="code-card"
content-style="height: calc(100% - 44px); padding: 0;"
>
<CodeEditor
:language="editorLanguage"
v-model="tutorial.code"
height="100%"
/>
</n-card>
</n-gi>
</n-grid>
<!-- 手机端布局 -->
<template v-if="tutorial.id && !isDesktop">
<n-tabs type="line" animated v-model:value="activeTab">
<n-tab-pane name="catalog" tab="目录">
<n-list hoverable clickable>
<n-list-item
v-for="(item, index) in titles"
:key="item.id"
@click="goToLesson(index + 1)"
>
<n-text
:type="step === index + 1 ? 'primary' : undefined"
:strong="step === index + 1"
>
{{ index + 1 }}. {{ item.title }}
</n-text>
</n-list-item>
</n-list>
</n-tab-pane>
<n-tab-pane name="content" :tab="`第 ${step} 课`">
<template v-for="(seg, i) in segments" :key="i">
<MdPreview
v-if="seg.type === 'md'"
preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'"
:model-value="seg.content"
/>
<ExerciseWidget
v-else
:exercise="seg.exercise"
:lang="tutorial.type"
/>
</template>
</n-tab-pane>
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code">
<CodeEditor :language="editorLanguage" v-model="tutorial.code" />
</n-tab-pane>
</n-tabs>
<n-divider style="margin: 12px 0" />
<n-flex align="center" justify="space-between">
<n-button
secondary
type="primary"
:disabled="isFirstLesson"
@click="goToPrevLesson"
>
上一课
</n-button>
<n-text>{{ step }} / {{ titles.length }}</n-text>
<n-button
secondary
type="primary"
:disabled="isLastLesson"
@click="goToNextLesson"
>
下一课
</n-button>
</n-flex>
</template>
<n-empty
v-if="isEmpty"
description="该教程还没有公开"
style="margin-top: 80px"
/>
</div>
</template>
<script setup lang="ts">
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import type { Tutorial, Exercise, LANGUAGE } from "utils/types"
import { getTutorial, getTutorials, getExercises } from "../api"
import { parseExercises } from "./composables/useExerciseParse"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress"
const ExerciseWidget = defineAsyncComponent(
() => import("./components/ExerciseWidget.vue"),
)
const CodeEditor = defineAsyncComponent(
() => import("shared/components/CodeEditor.vue"),
)
const isDark = useDark()
const route = useRoute()
const router = useRouter()
const { isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const step = computed(() => {
const value = route.params.step as string | undefined
if (!value) return 1
return parseInt(value)
})
const type = computed<"python" | "c">(() =>
route.params.type === "c" ? "c" : "python",
)
const tutorial = ref<Partial<Tutorial>>({
id: 0,
title: "",
content: "",
code: "",
})
const editorLanguage = computed<LANGUAGE>(() =>
tutorial.value.type === "c" ? "C" : "Python3",
)
const titles = ref<{ id: number; title: string }[]>([])
const exercises = ref<Exercise[]>([])
const activeTab = ref("content")
const isEmpty = ref(false)
const segments = computed(() =>
parseExercises(tutorial.value.content ?? "", exercises.value),
)
const isFirstLesson = computed(() => step.value === 1)
const isLastLesson = computed(() => step.value === titles.value.length)
function goToLesson(lessonNumber: number) {
activeTab.value = "content"
router.push(
`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`,
)
}
function goToPrevLesson() {
if (step.value > 1) goToLesson(step.value - 1)
}
function goToNextLesson() {
if (step.value < titles.value.length) goToLesson(step.value + 1)
}
async function init() {
const res1 = await getTutorials(type.value)
titles.value = res1.data
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),
])
if (res2.status === "fulfilled") tutorial.value = res2.value.data
exercises.value = exs.status === "fulfilled" ? exs.value : []
learnStep.value[type.value] = step.value
}
watch(
() => [route.params.type, route.params.step],
async () => {
if (route.name === "learn") init()
},
{ immediate: true },
)
</script>
<style scoped>
/* 桌面端固定高度,让目录/内容/代码三栏各自内部滚动;移动端不限高,交给页面整体滚动 */
@media (min-width: 769px) {
.learn-container {
height: calc(100vh - 138px);
}
}
.learn-grid {
height: 100%;
}
.learn-col {
overflow-y: auto;
height: 100%;
}
.learn-col--code {
overflow-y: hidden;
}
.code-card {
height: 100%;
}
</style>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { SOURCES } from "utils/constants"
import CodeEditor from "shared/components/CodeEditor.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { provideSyncStatus } from "oj/composables/syncStatus"
import storage from "utils/storage"
import type { LANGUAGE } from "utils/types"
import Form from "./Form.vue"
const route = useRoute()
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const { isDesktop } = useBreakpoints()
// 提供空的同步状态,避免 Form 组件注入错误
// 在竞赛模式下,同步功能会被 showSyncFeature 自动禁用
provideSyncStatus()
const contestID = route.params.contestID || null
const storageKey = computed(
() =>
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
)
const editorHeight = computed(() =>
isDesktop.value ? "calc(100vh - 133px)" : "calc(100vh - 172px)",
)
onMounted(() => {
const savedCode = storage.get(storageKey.value)
codeStore.setCode(
savedCode ||
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
})
const changeCode = (v: string) => {
storage.set(storageKey.value, v)
}
const changeLanguage = (v: LANGUAGE) => {
const savedCode = storage.get(storageKey.value)
codeStore.setCode(
savedCode && storageKey.value.split("_").pop() === v
? savedCode
: problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
}
</script>
<template>
<n-flex vertical>
<Form :storage-key="storageKey" @change-language="changeLanguage" />
<CodeEditor
v-model:value="codeStore.code.value"
:language="codeStore.code.language"
:height="editorHeight"
@update:model-value="changeCode"
/>
</n-flex>
</template>

View File

@@ -0,0 +1,161 @@
<script lang="ts" setup>
import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { SOURCES } from "utils/constants"
import CodeEditor from "shared/components/CodeEditor.vue"
import storage from "utils/storage"
import { createTestSubmission } from "utils/judge"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import type { DropdownOption } from "naive-ui"
import { copyToClipboard } from "utils/functions"
const message = useMessage()
const route = useRoute()
const contestID = !!route.params.contestID ? route.params.contestID : null
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const { input, output } = storeToRefs(codeStore)
const { problem } = storeToRefs(problemStore)
const storageKey = computed(
() =>
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
)
onMounted(() => {
if (storage.get(storageKey.value)) {
codeStore.setCode(storage.get(storageKey.value))
} else {
codeStore.setCode(
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
}
})
function changeCode(v: string) {
storage.set(storageKey.value, v)
}
function changeLanguage(v: string) {
if (
storage.get(storageKey.value) &&
storageKey.value.split("_").pop() === v
) {
codeStore.setCode(storage.get(storageKey.value))
} else {
codeStore.setCode(
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
}
}
const copy = async () => {
const success = await copyToClipboard(codeStore.code.value)
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
}
const reset = () => {
codeStore.setCode(
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
storage.remove(storageKey.value)
message.success("代码重置成功")
}
const runCode = async () => {
const res = await createTestSubmission(codeStore.code, input.value)
output.value = res.output
}
const languageOptions: DropdownOption[] = problem.value!.languages.map(
(it) => ({
label: () => LANGUAGE_SHOW_VALUE[it],
value: it,
}),
)
</script>
<template>
<n-flex vertical style="height: calc(100vh - 92px)">
<n-split direction="horizontal" :min="1 / 3" :max="4 / 5">
<template #1>
<n-flex vertical>
<n-flex align="center">
<n-select
v-model:value="codeStore.code.language"
style="width: 120px"
:options="languageOptions"
@update:value="changeLanguage"
/>
<n-button @click="copy">复制代码</n-button>
<n-button @click="reset">重置代码</n-button>
<n-button type="primary" secondary @click="runCode">
运行代码
</n-button>
</n-flex>
<CodeEditor
v-model:value="codeStore.code.value"
@update:model-value="changeCode"
:language="codeStore.code.language"
/>
</n-flex>
</template>
<template #2>
<n-split
direction="vertical"
:default-size="1 / 3"
:min="1 / 5"
:max="3 / 5"
>
<template #1>
<div class="title">输入框</div>
<n-input
v-model:value="input"
type="textarea"
:bordered="false"
:resizable="false"
class="box"
/>
</template>
<template #2>
<div class="title">输出框</div>
<n-input
class="box output"
v-model:value="output"
placeholder=""
type="textarea"
:bordered="false"
:resizable="false"
readonly
/>
</template>
</n-split>
</template>
</n-split>
</n-flex>
</template>
<style scoped>
.title {
height: 40px;
line-height: 40px;
padding-left: 20px;
font-size: 16px;
}
.box {
padding-left: 10px;
box-sizing: border-box;
height: calc(100% - 40px);
font-size: 20px;
}
.output {
font-family: "Monaco";
}
</style>

View File

@@ -0,0 +1,291 @@
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { copyToClipboard, utoa } from "utils/functions"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { injectSyncStatus } from "oj/composables/syncStatus"
import { SYNC_MESSAGES } from "shared/composables/sync"
import {
ICON_SET,
LANGUAGE_FORMAT_VALUE,
LANGUAGE_SHOW_VALUE,
SOURCES,
STORAGE_KEY,
} from "utils/constants"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import storage from "utils/storage"
import type { LANGUAGE } from "utils/types"
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
import { Icon } from "@iconify/vue"
import { NFlex } from "naive-ui"
import SubmitCode from "./SubmitCode.vue"
const SubmitFlowchart = defineAsyncComponent(
() => import("./SubmitFlowchart.vue"),
)
interface Props {
storageKey: string
isConnected?: boolean // WebSocket 实际的连接状态(已建立/未建立)
}
const { storageKey, isConnected = false } = defineProps<Props>()
// 注入同步状态
const syncStatus = injectSyncStatus()
const emit = defineEmits<{
changeLanguage: [v: LANGUAGE]
toggleSync: [v: boolean]
}>()
const message = useMessage()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const { problem, languages } = storeToRefs(problemStore)
const { isDesktop } = useBreakpoints()
const syncEnabled = ref(false) // 用户点击按钮后的意图状态(想要开启/关闭)
const statisticPanel = ref(false)
// 计算属性
const isContestMode = computed(() => route.name === "contest problem")
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
const showSyncFeature = computed(
() =>
isDesktop.value &&
userStore.isAuthed &&
codeStore.code.language !== "Flowchart" &&
!isContestMode.value,
)
const showGoSubmissionButton = computed(() => {
if (isContestMode.value) return true
else if (userStore.isAdminRole) return true
else if (userStore.showSubmissions) return true
else return false
})
const menuOptions = computed<DropdownOption[]>(() => {
const options: DropdownOption[] = []
// 移动端额外收纳桌面端常驻的两项
if (!isDesktop.value) {
if (showGoSubmissionButton.value) {
options.push({
label: "提交信息",
key: "submissions",
})
}
if (userStore.isTeacherOrAbove) {
options.push({
label: "课堂统计",
key: "statistics",
})
}
}
if (codeStore.code.language !== "Flowchart") {
if (codeStore.code.language !== "SQL") {
options.push({
label: "去自测猫",
key: "testcat",
})
}
options.push({
label: "复制代码",
key: "copy",
})
options.push({
label: "重置代码",
key: "reset",
})
}
if (isDesktop.value && userStore.isSuperAdmin) {
options.push({
label: "编辑题目",
key: "edit",
})
}
return options
})
const handleMenuSelect = (key: string) => {
switch (key) {
case "submissions":
goSubmissions()
break
case "statistics":
statisticPanel.value = true
break
case "testcat":
goTestCat()
break
case "copy":
copy()
break
case "reset":
reset()
break
case "edit":
goEdit()
break
}
}
const languageOptions: DropdownOption[] = languages.value.map((it) => ({
label: () =>
h(NFlex, { align: "center" }, () => [
h(Icon, {
icon: ICON_SET[it],
width: 16,
}),
LANGUAGE_SHOW_VALUE[it],
]),
value: it,
}))
const copy = async () => {
const success = await copyToClipboard(codeStore.code.value)
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
}
const reset = () => {
codeStore.setCode(
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
storage.remove(storageKey)
message.success("代码重置成功")
}
const changeLanguage = (v: LANGUAGE) => {
storage.set(STORAGE_KEY.LANGUAGE, v)
emit("changeLanguage", v)
}
const goTestCat = () => {
const lang = LANGUAGE_FORMAT_VALUE[codeStore.code.language]
const data = {
lang,
code: codeStore.code.value,
input: problemStore.problem?.samples[0].input,
}
const base64 = utoa(JSON.stringify(data))
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
window.open(url, "_blank")
}
const goSubmissions = () => {
const name = route.params.contestID ? "contest submissions" : "submissions"
router.push({ name, query: { problem: problem.value!._id } })
}
const goEdit = () => {
const url = problem.value!.contest
? `/admin/contest/${problem.value!.contest}/problem/edit/${problem.value!.id}`
: `/admin/problem/edit/${problem.value!.id}`
window.open(router.resolve(url).href, "_blank")
}
const toggleSync = () => {
syncEnabled.value = !syncEnabled.value
emit("toggleSync", syncEnabled.value)
}
defineExpose({
resetSyncStatus: () => {
syncEnabled.value = false
},
})
onMounted(() => {
if (!languages.value.includes(codeStore.code.language)) {
// 回退到题目支持的第一种语言(如 SQL 题只有 "SQL",硬编码 Python3 会被后端拒绝)
codeStore.code.language = languages.value[0] ?? "Python3"
}
})
</script>
<template>
<n-flex align="center">
<n-select
v-model:value="codeStore.code.language"
style="width: 120px"
:size="buttonSize"
:options="languageOptions"
@update:value="changeLanguage"
/>
<SubmitFlowchart v-if="codeStore.code.language === 'Flowchart'" />
<SubmitCode v-else />
<n-button
v-if="isDesktop && showGoSubmissionButton"
:size="buttonSize"
@click="goSubmissions"
>
提交信息
</n-button>
<n-button
v-if="isDesktop && userStore.isTeacherOrAbove"
:size="buttonSize"
@click="statisticPanel = true"
>
课堂统计
</n-button>
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单移动端再加上提交信息 / 课堂统计 -->
<n-dropdown
v-if="menuOptions.length"
trigger="click"
:options="menuOptions"
@select="handleMenuSelect"
>
<n-button :size="buttonSize">更多操作</n-button>
</n-dropdown>
<template v-if="showSyncFeature">
<n-button
:size="buttonSize"
:type="syncEnabled ? 'warning' : 'default'"
@click="toggleSync"
>
{{ syncEnabled ? SYNC_MESSAGES.SYNC_ON : SYNC_MESSAGES.SYNC_OFF }}
</n-button>
<!-- 同步状态标签 -->
<template v-if="isConnected">
<n-tag v-if="syncStatus.otherUser.value" type="info">
{{ SYNC_MESSAGES.SYNCING_WITH(syncStatus.otherUser.value.name) }}
</n-tag>
<n-tag
v-if="
userStore.isSuperAdmin &&
!syncStatus.otherUser.value &&
syncStatus.hadConnection.value
"
type="warning"
>
{{ SYNC_MESSAGES.STUDENT_LEFT(syncStatus.lastLeftUser.value?.name) }}
</n-tag>
</template>
</template>
</n-flex>
<n-modal
v-if="userStore.isTeacherOrAbove"
v-model:show="statisticPanel"
preset="card"
title="提交记录的统计"
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
>
<StatisticsPanel :problem="problem!._id" username="" />
</n-modal>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { useMyFlowchartStore } from "shared/store/myFlowchart"
import { useMermaid } from "shared/composables/useMermaid"
const store = useMyFlowchartStore()
const { renderError, renderFlowchart } = useMermaid()
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
watch(
() => store.mermaidCode,
async (code) => {
if (!code) return
await nextTick()
await renderFlowchart(mermaidContainer.value, code)
},
{ immediate: true },
)
</script>
<template>
<div style="padding: 8px 0">
<n-alert v-if="renderError" type="error" title="渲染失败" size="small">
{{ renderError }}
</n-alert>
<div v-else ref="mermaidContainer" class="flowchart-container"></div>
</div>
</template>
<style scoped>
.flowchart-container {
width: 100%;
min-height: 500px;
display: flex;
justify-content: center;
align-items: flex-start;
}
:deep(.flowchart-container > svg) {
width: 100%;
height: auto;
}
</style>

View File

@@ -0,0 +1,550 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { createTestSubmission } from "utils/judge"
import { DIFFICULTY } from "utils/constants"
import type { Problem, ProblemStatus } from "utils/types"
import Copy from "shared/components/Copy.vue"
import { useDark } from "@vueuse/core"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { getSimilarProblems } from "oj/api"
import SQLDataTable from "./SQLDataTable.vue"
type Sample = Problem["samples"][number] & {
id: number
msg: string
status: ProblemStatus
loading: boolean
}
const theme = useThemeVars()
const style = computed(() => "color: " + theme.value.primaryColor)
const isDark = useDark()
const route = useRoute()
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const problemSetId = computed(() => route.params.problemSetId)
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
const isSQL = computed(() => !!problem.value?.sql_config)
const sqlDisplay = computed(() => problem.value?.sql_display ?? null)
const sqlExpectedQuery = computed(() => {
const exp = sqlDisplay.value?.expected
return exp && "columns" in exp ? exp : null
})
const sqlChangedTables = computed(() => {
const exp = sqlDisplay.value?.expected
return exp && "changed_tables" in exp ? exp.changed_tables : []
})
const router = useRouter()
// 相似题目推荐
const similarProblems = ref<any[]>([])
const similarLoaded = ref(false)
async function loadSimilarProblems() {
if (similarLoaded.value || !problem.value) return
try {
const res = await getSimilarProblems(problem.value._id)
similarProblems.value = res.data || []
} catch {
similarProblems.value = []
}
similarLoaded.value = true
}
// 切换题目时重置相似推荐状态
watch(
() => problem.value?._id,
() => {
similarProblems.value = []
similarLoaded.value = false
},
)
// AC 或失败次数 >= 3 时加载推荐
watch(
() => [problem.value?._id, problem.value?.my_status, problemStore.failCount],
([, status, failCount]) => {
if (status === 0 || (failCount as number) >= 3) {
loadSimilarProblems()
}
},
{ immediate: true },
)
const hasTriedButNotPassed = computed(() => {
return (
problem.value?.my_status !== undefined &&
problem.value?.my_status !== null &&
problem.value?.my_status !== 0
)
})
const samples = ref<Sample[]>(
problem.value!.samples.map((sample, index) => ({
...sample,
id: index,
msg: "",
status: "not_test",
loading: false,
})),
)
const NODE_TARGET_LABELS: Record<string, string> = {
for_loop: "for 循环",
while_loop: "while 循环",
if_statement: "if 条件",
else_clause: "else 子句",
function_definition: "函数定义",
return: "return 语句",
break: "break 语句",
continue: "continue 语句",
list_comprehension: "列表推导式",
list_literal: "列表",
dict_literal: "字典",
set_literal: "集合",
f_string: "f-string",
try_except: "try-except",
class_definition: "类定义",
}
type AstRule = {
engine: string
target?: string
label?: string
exact?: number
min?: number
max?: number
message: string
}
function ruleDescription(rule: AstRule): string {
if (rule.message) return rule.message
const target = rule.target || ""
const targetLabel = rule.label || NODE_TARGET_LABELS[target] || target
const countDesc = () => {
if (rule.exact !== undefined) return `出现 ${rule.exact}`
if (rule.min !== undefined && rule.max !== undefined)
return `出现 ${rule.min}${rule.max}`
if (rule.min !== undefined) return `至少出现 ${rule.min}`
if (rule.max !== undefined) return `至多出现 ${rule.max}`
return ""
}
const callDesc = () => {
if (rule.exact !== undefined) return `调用 ${rule.exact}`
if (rule.min !== undefined && rule.max !== undefined)
return `调用 ${rule.min}${rule.max}`
if (rule.min !== undefined) return `至少调用 ${rule.min}`
if (rule.max !== undefined) return `至多调用 ${rule.max}`
return ""
}
switch (rule.engine) {
case "must_exist_node":
return `必须使用 ${targetLabel}`
case "must_not_exist_node":
return `不能使用 ${targetLabel}`
case "count_node":
return `${targetLabel} ${countDesc()}`
case "must_call_function":
return `必须调用 ${target}()`
case "must_not_call_function":
return `不能调用 ${target}()`
case "count_function_call":
return `${target}() ${callDesc()}`
case "must_call_method":
return `必须调用 .${target}()`
case "must_not_call_method":
return `不能调用 .${target}()`
case "must_use_operator":
return `必须使用 ${target} 运算符`
default:
return rule.engine
}
}
function ruleTagType(engine: string): "error" | "success" | "info" {
if (engine.startsWith("must_not")) return "error"
if (engine.startsWith("must")) return "success"
return "info"
}
const astRulesForDisplay = computed(() => {
if (!problem.value?.ast_rules) return []
return Object.entries(problem.value.ast_rules).filter(
([, rules]) => rules.length > 0,
)
})
async function test(sample: Sample, index: number) {
samples.value = samples.value.map((sample) => {
if (sample.id === index) {
sample.loading = true
}
return sample
})
const res = await createTestSubmission(codeStore.code, sample.input)
samples.value = samples.value.map((sample) => {
if (sample.id === index) {
const status =
res.status === 3 && res.output.trim() === sample.output
? "passed"
: "failed"
return {
...sample,
msg: res.output,
status: status,
loading: false,
}
} else {
return sample
}
})
const id = setTimeout(() => {
clearTimeout(id)
samples.value = samples.value.map((sample) => {
if (sample.id === index) {
return {
...sample,
msg: res.output,
status: "not_test",
loading: false,
}
} else {
return sample
}
})
}, 2000)
}
function label(status: ProblemStatus, loading: boolean) {
if (loading) return "测试中"
return {
not_test: "测试",
failed: "不通过",
passed: "通过",
}[status]
}
function type(status: ProblemStatus) {
return {
not_test: "",
failed: "error",
passed: "success",
}[status] as "warning" | "error" | "success"
}
</script>
<template>
<div v-if="problem">
<template v-if="!problemSetId">
<!-- 已通过 -->
<n-alert
class="status-alert"
v-if="problem.my_status === 0"
type="success"
title="🎉 本 题 已 经 被 你 解 决 啦"
>
</n-alert>
<!-- 尝试过但未通过 -->
<n-alert
class="status-alert"
v-else-if="hasTriedButNotPassed"
type="warning"
title="💪 你已经尝试过这道题,但还没有通过"
>
不要放弃仔细检查代码逻辑或者寻求 AI 的帮助获取灵感
</n-alert>
</template>
<n-flex align="center">
<n-tag>{{ problem._id }}</n-tag>
<h2 class="problemTitle">{{ problem.title }}</h2>
</n-flex>
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:checklist"></Icon>
描述
</n-flex>
</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.description"
:theme="isDark ? 'dark' : 'light'"
/>
<template v-if="!isSQL">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:envelope-back-front"></Icon>
输入
</n-flex>
</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.input_description"
:theme="isDark ? 'dark' : 'light'"
/>
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:mailbox-post"></Icon>
输出
</n-flex>
</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.output_description"
:theme="isDark ? 'dark' : 'light'"
/>
</template>
<template v-if="isSQL && sqlDisplay">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="devicon:sqlite"></Icon>
数据表
</n-flex>
</p>
<div v-for="t in sqlDisplay.tables" :key="t.name">
<p class="sqlTableName">{{ t.name }}</p>
<SQLDataTable
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:check-button"></Icon>
期望结果
</n-flex>
</p>
<template v-if="sqlExpectedQuery">
<SQLDataTable
:columns="sqlExpectedQuery.columns"
:rows="sqlExpectedQuery.rows"
:total-rows="sqlExpectedQuery.total_rows"
:truncated="sqlExpectedQuery.truncated"
/>
<p v-if="!problem.sql_config?.order_sensitive" class="sqlNote">
结果顺序不限
</p>
</template>
<div v-for="t in sqlChangedTables" :key="t.name">
<p class="sqlTableName">
{{ t.dropped ? `${t.name} 表已被删除` : `执行后的 ${t.name}` }}
</p>
<SQLDataTable
v-if="!t.dropped"
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
</template>
<div v-if="problem.hint">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-emojis:man-tipping-hand-1"></Icon>
提示
</n-flex>
</p>
<MdPreview
preview-theme="preview"
:model-value="problem.hint"
:theme="isDark ? 'dark' : 'light'"
/>
</div>
<!-- 代码要求AST 规则 -->
<div v-if="astRulesForDisplay.length > 0">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:check-button"></Icon>
要求
</n-flex>
</p>
<div v-for="[lang, rules] in astRulesForDisplay" :key="lang">
<p v-if="astRulesForDisplay.length > 1" class="lang-label">
{{ lang }}
</p>
<n-list bordered style="margin-bottom: 8px">
<n-list-item v-for="(rule, i) in rules" :key="i">
<n-flex align="center">
<n-tag :type="ruleTagType(rule.engine)">
{{ ruleDescription(rule) }}
</n-tag>
<span v-if="rule.message" class="rule-message">{{
rule.message
}}</span>
</n-flex>
</n-list-item>
</n-list>
</div>
</div>
<template v-if="!isSQL">
<div v-for="(sample, index) of samples" :key="index">
<n-flex align="center">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-emojis:microscope"></Icon>
例子 {{ index + 1 }}
</n-flex>
</p>
<n-button
size="small"
:type="type(sample.status)"
@click="test(sample, index)"
>
{{ label(sample.status, sample.loading) }}
</n-button>
</n-flex>
<n-descriptions
bordered
:column="2"
label-style="width: 50%; min-width: 100px"
>
<n-descriptions-item>
<template #label>
<n-flex>
<span>输入</span>
<Copy :value="sample.input" />
</n-flex>
</template>
<div class="testcase">{{ sample.input }}</div>
</n-descriptions-item>
<n-descriptions-item>
<template #label>
<n-flex>
<span>输出</span>
<Copy :value="sample.output" />
</n-flex>
</template>
<div class="testcase">{{ sample.output }}</div>
</n-descriptions-item>
<n-descriptions-item label="运行结果" v-if="sample.msg">
<div class="testcase">{{ sample.msg }}</div>
</n-descriptions-item>
</n-descriptions>
</div>
</template>
<div v-if="problem.source">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:book-open-bookmark"></Icon>
来源
</n-flex>
</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.source"
:theme="isDark ? 'dark' : 'light'"
/>
</div>
<!-- 相似题目推荐 -->
<div v-if="similarProblems.length > 0">
<n-divider />
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-ultimate-color:like"></Icon>
相似题目推荐
</n-flex>
</p>
<n-list bordered>
<n-list-item v-for="sp in similarProblems" :key="sp._id">
<n-flex align="center" justify="space-between">
<n-flex align="center">
<n-tag size="small">{{ sp._id }}</n-tag>
<n-button
text
type="info"
@click="
router.push({
name: 'problem',
params: { problemID: sp._id },
})
"
>
{{ sp.title }}
</n-button>
</n-flex>
<n-tag
size="small"
:type="
sp.difficulty === 'Low'
? 'success'
: sp.difficulty === 'High'
? 'error'
: 'warning'
"
>
{{
DIFFICULTY[sp.difficulty as keyof typeof DIFFICULTY] || "中等"
}}
</n-tag>
</n-flex>
</n-list-item>
</n-list>
</div>
</div>
</template>
<style scoped>
.problemTitle {
margin: 0;
}
.title {
font-size: 20px;
margin: 12px 0;
}
.testcase {
font-size: 14px;
white-space: pre;
font-family: "Monaco";
}
.status-alert {
margin-bottom: 16px;
}
.lang-label {
font-weight: 600;
margin: 8px 0 4px;
}
.rule-message {
font-size: 13px;
opacity: 0.65;
}
.sqlTableName {
font-weight: 600;
margin: 8px 0 4px;
font-family: Monaco, Consolas, monospace;
}
.sqlNote {
font-size: 13px;
opacity: 0.65;
margin: 0 0 8px;
}
</style>

View File

@@ -0,0 +1,123 @@
<script lang="ts" setup>
import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { provideSyncStatus } from "oj/composables/syncStatus"
import { SOURCES } from "utils/constants"
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import storage from "utils/storage"
import type { LANGUAGE } from "utils/types"
import Form from "./Form.vue"
const FlowchartEditor = defineAsyncComponent(
() => import("shared/components/FlowchartEditor/index.vue"),
)
const route = useRoute()
const formRef = useTemplateRef<InstanceType<typeof Form>>("formRef")
const flowchartEditorRef = useTemplateRef("flowchartEditorRef")
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const { isDesktop } = useBreakpoints()
const sync = ref(false)
// 提供同步状态给子组件使用
const syncStatus = provideSyncStatus()
const contestID = route.params.contestID || null
const storageKey = computed(
() =>
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
)
const editorHeight = computed(() =>
isDesktop.value ? "calc(100vh - 133px)" : "calc(100vh - 172px)",
)
function loadCode() {
const savedCode = storage.get(storageKey.value)
codeStore.setCode(
savedCode ||
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
}
onMounted(loadCode)
watch(() => problem.value?._id, loadCode)
watch(
() => codeStore.code.value,
(v) => {
storage.set(storageKey.value, v)
},
)
const changeCode = (v: string) => {
storage.set(storageKey.value, v)
}
const changeLanguage = (v: LANGUAGE) => {
const savedCode = storage.get(storageKey.value)
codeStore.setCode(
savedCode && storageKey.value.split("_").pop() === v
? savedCode
: problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
}
const toggleSync = (value: boolean) => {
sync.value = value
if (!value) {
syncStatus.reset()
}
}
const handleSyncClosed = () => {
sync.value = false
syncStatus.reset()
formRef.value?.resetSyncStatus()
}
const handleSyncStatusChange = (status: {
otherUser?: { name: string; isSuperAdmin: boolean }
}) => {
syncStatus.setOtherUser(status.otherUser)
}
// 提供FlowchartEditor的ref给子组件
provide("flowchartEditorRef", flowchartEditorRef)
</script>
<template>
<n-flex vertical>
<Form
ref="formRef"
:storage-key="storageKey"
:is-connected="sync"
@change-language="changeLanguage"
@toggle-sync="toggleSync"
/>
<FlowchartEditor
v-if="codeStore.code.language === 'Flowchart'"
ref="flowchartEditorRef"
/>
<SyncCodeEditor
v-else
v-model:value="codeStore.code.value"
:sync="sync"
:problem="problem!._id"
:language="codeStore.code.language"
:height="editorHeight"
@update:model-value="changeCode"
@sync-closed="handleSyncClosed"
@sync-status-change="handleSyncStatusChange"
/>
</n-flex>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { useProblemStore } from "oj/store/problem"
import { useMermaid } from "shared/composables/useMermaid"
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
const { renderError, renderFlowchart } = useMermaid()
const renderProblemFlowchart = async () => {
await renderFlowchart(
mermaidContainer.value,
problem.value?.mermaid_code ?? "",
)
}
onMounted(renderProblemFlowchart)
watch(() => problem.value?.mermaid_code, renderProblemFlowchart)
</script>
<template>
<div>
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
<template #default>
{{ renderError }}
</template>
</n-alert>
<div v-else ref="mermaidContainer" class="container"></div>
</div>
</template>
<style scoped>
.container {
width: 100%;
max-width: 100%;
min-height: 300px;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -0,0 +1,177 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
import { getACRateNumber, getTagColor, parseTime } from "utils/functions"
import { Pie } from "vue-chartjs"
import {
Chart as ChartJS,
ArcElement,
Title,
Tooltip,
Legend,
Colors,
} from "chart.js"
import { getProblemBeatRate } from "oj/api"
import { getProblemYearlyAC, type YearlyACData } from "oj/api"
import ProblemYearlyChart from "./ProblemYearlyChart.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
// 仅注册饼图所需的 Chart.js 组件
ChartJS.register(ArcElement, Title, Tooltip, Legend, Colors)
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const { isDesktop } = useBreakpoints()
const beatRate = ref("0")
const yearlyACData = ref<YearlyACData[]>([])
const data = computed(() => {
const status = problem.value!.statistic_info
const labels = []
for (let i in status) {
if (status[i] !== 0) {
// @ts-ignore
labels.push(JUDGE_STATUS[i]["name"])
}
}
return {
labels,
datasets: [
{ data: Object.values(status), hoverOffset: 5, borderRadius: 10 },
],
}
})
const numbers = computed(() => {
return [
{
icon: "streamline-ultimate-color:checklist",
title: problem.value?.submission_number ?? 0,
content: "总提交",
int: true,
suffix: "",
},
{
icon: "streamline-emojis:woman-raising-hand-2",
title: problem.value?.accepted_number ?? 0,
content: "通过数",
int: true,
suffix: "",
},
{
icon: "fluent-emoji:chart-increasing",
title: getACRateNumber(
problem.value?.accepted_number ?? 0,
problem.value?.submission_number ?? 0,
),
content: "通过率",
int: false,
suffix: "%",
},
{
icon: "streamline-emojis:sparkles",
title: parseFloat(beatRate.value),
content: "击败用户",
int: false,
suffix: "%",
},
]
})
const options = {
plugins: {
title: { text: "提交结果的比例", display: true, font: { size: 20 } },
},
}
async function getBeatRate() {
const res = await getProblemBeatRate(problem.value!.id)
beatRate.value = res.data
}
async function getYearlyAC() {
const res = await getProblemYearlyAC(problem.value!._id)
yearlyACData.value = res.data
}
onMounted(() => {
getBeatRate()
getYearlyAC()
})
</script>
<template>
<n-descriptions
bordered
label-placement="left"
:column="isDesktop ? 3 : 1"
v-if="problem"
>
<n-descriptions-item label="编号">
{{ problem._id }}
</n-descriptions-item>
<n-descriptions-item label="出题人">
{{ problem.created_by.username }}
</n-descriptions-item>
<n-descriptions-item label="创建时间">
{{ parseTime(problem.create_time) }}
</n-descriptions-item>
<n-descriptions-item label="难度">
<n-tag :type="getTagColor(problem.difficulty)">
{{ DIFFICULTY[problem.difficulty] }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item :span="2" label="标签">
<n-flex>
<n-tag type="info" v-for="tag in problem.tags" :key="tag">
{{ tag }}
</n-tag>
</n-flex>
</n-descriptions-item>
</n-descriptions>
<n-grid :cols="isDesktop ? 4 : 2" :x-gap="10" :y-gap="10" class="cards">
<n-gi v-for="item in numbers" :key="item.content">
<n-card hoverable>
<n-flex vertical align="center">
<Icon v-if="isDesktop" :icon="item.icon" width="40" />
<n-h2 class="number">
<n-number-animation
:to="item.title"
:precision="item.int ? 0 : 2"
/>
<span v-if="item.suffix">{{ item.suffix }}</span>
</n-h2>
<n-h4 class="number-label">{{ item.content }}</n-h4>
</n-flex>
</n-card>
</n-gi>
</n-grid>
<div class="pie" v-if="problem && problem.submission_number > 0">
<Pie :data="data" :options="options" />
</div>
<ProblemYearlyChart :data="yearlyACData" />
</template>
<style scoped>
.cards {
margin-top: 24px;
}
.number {
margin: 0;
font-weight: bold;
}
.number-label {
margin: 0;
}
.pie {
width: 100%;
max-width: 500px;
margin: 24px auto;
}
</style>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import type { ProblemFiltered } from "utils/types"
import { Icon } from "@iconify/vue"
defineProps<{
problem: ProblemFiltered
}>()
</script>
<template>
<n-flex align="center">
<span>{{ problem.title }}</span>
<Icon
v-if="problem.allow_flowchart"
width="18"
icon="vscode-icons:file-type-drawio"
/>
<Icon
v-else-if="problem.show_flowchart"
width="18"
icon="vscode-icons:file-type-graphql"
/>
<Icon
v-if="problem.has_ast_rules"
width="18"
icon="vscode-icons:file-type-light-todo"
/>
</n-flex>
</template>

View File

@@ -0,0 +1,698 @@
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { storeToRefs } from "pinia"
import type { CSSProperties } from "vue"
import { getReaction, setReaction } from "oj/api"
import { useProblemStore } from "oj/store/problem"
import { useUserStore } from "shared/store/user"
import { REACTIONS } from "utils/constants"
import type { ReactionCounts, ReactionKey } from "utils/types"
const emit = defineEmits<{ submitted: [] }>()
const userStore = useUserStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const message = useMessage()
const theme = useThemeVars()
const mine = ref<ReactionKey | null>(null)
const counts = ref<ReactionCounts | null>(null)
const loading = ref(false)
// 正在提交的 key用来锁住整组并只在当前选项显示进度。
const submitting = ref<ReactionKey | null>(null)
const activeIndex = ref<number | null>(null)
const keyboardActive = ref(false)
const wheelRef = ref<HTMLElement | null>(null)
let loadSequence = 0
const wheelGeometry = {
startAngle: -90,
contentRadius: 35,
pushRadius: 4,
outerRadius: 50,
arcPointCount: 9,
hitInnerRadius: 0.18,
hitOuterRadius: 0.49,
} as const
const sliceAngle = 360 / REACTIONS.length
function pointOnCircle(angle: number, radius: number) {
const radians = (angle * Math.PI) / 180
return {
x: 50 + Math.cos(radians) * radius,
y: 50 + Math.sin(radians) * radius,
}
}
function getWheelItemStyle(index: number): CSSProperties {
const centerAngle = wheelGeometry.startAngle + index * sliceAngle
const startAngle = centerAngle - sliceAngle / 2
const endAngle = centerAngle + sliceAngle / 2
const dividerAngle = index * sliceAngle - sliceAngle / 2
const position = pointOnCircle(centerAngle, wheelGeometry.contentRadius)
const push = pointOnCircle(centerAngle, wheelGeometry.pushRadius)
const arcPoints = Array.from(
{ length: wheelGeometry.arcPointCount },
(_, pointIndex) => {
const progress = pointIndex / (wheelGeometry.arcPointCount - 1)
const angle = startAngle + (endAngle - startAngle) * progress
const point = pointOnCircle(angle, wheelGeometry.outerRadius)
return `${point.x.toFixed(3)}% ${point.y.toFixed(3)}%`
},
)
return {
"--segment-path": `polygon(50% 50%, ${arcPoints.join(", ")})`,
"--content-x": `${position.x}%`,
"--content-y": `${position.y}%`,
"--push-x": `${push.x - 50}px`,
"--push-y": `${push.y - 50}px`,
"--divider-angle": `${dividerAngle}deg`,
}
}
const wheelItems = REACTIONS.map((item, index) => ({
...item,
index,
style: getWheelItemStyle(index),
}))
const solved = computed(() => problem.value?.my_status === 0)
const locked = computed(() => mine.value !== null)
const canInteract = computed(
() =>
userStore.isAuthed &&
!!problem.value &&
solved.value &&
!locked.value &&
!loading.value &&
!submitting.value,
)
const wheelCenter = computed(() => {
if (loading.value) {
return {
icon: "ph:spinner-gap-bold",
eyebrow: "正在读取",
label: "题目点评",
spinning: true,
}
}
if (submitting.value) {
const item = REACTIONS.find((reaction) => reaction.key === submitting.value)
return {
icon: "svg-spinners:180-ring-with-bg",
eyebrow: "正在记录",
label: item?.label ?? "提交点评",
spinning: false,
}
}
if (mine.value) {
const item = REACTIONS.find((reaction) => reaction.key === mine.value)
return {
icon: "ph:check-bold",
eyebrow: "你的选择",
label: item?.label ?? "已提交",
spinning: false,
}
}
if (!userStore.isAuthed) {
return {
icon: "ph:user-circle-dashed",
eyebrow: "登录后开放",
label: "登录后点评",
spinning: false,
}
}
if (activeIndex.value !== null) {
const item = wheelItems[activeIndex.value]
const count = counts.value?.[item.key]
return {
icon: item.icon,
eyebrow: count === undefined ? "选择这项" : `${count} 人选择`,
label: item.label,
spinning: false,
}
}
if (!solved.value) {
return {
icon: "ph:lock-simple-bold",
eyebrow: "完成后开放",
label: "通关后点评",
spinning: false,
}
}
return {
icon: "ph:cursor-click-bold",
eyebrow: "移动到扇区",
label: "选择点评",
spinning: false,
}
})
const reactionStyle = computed(() => ({
"--reaction-accent": theme.value.primaryColor,
"--reaction-card": theme.value.cardColor,
"--reaction-border": theme.value.borderColor,
"--reaction-text": theme.value.textColor1,
"--reaction-text-faint": theme.value.textColor3,
}))
function optionAriaLabel(key: ReactionKey, label: string) {
const count = counts.value?.[key]
const countText = count === undefined ? "" : `${count} 人选择`
const selectedText = mine.value === key ? ",你的选择" : ""
return `${label}${countText}${selectedText}`
}
function getPointerIndex(event: PointerEvent | MouseEvent) {
const wheel = wheelRef.value
if (!wheel) return null
const bounds = wheel.getBoundingClientRect()
const x = event.clientX - (bounds.left + bounds.width / 2)
const y = event.clientY - (bounds.top + bounds.height / 2)
const distance = Math.hypot(x, y)
if (
distance < bounds.width * wheelGeometry.hitInnerRadius ||
distance > bounds.width * wheelGeometry.hitOuterRadius
) {
return null
}
const angle = (Math.atan2(y, x) * 180) / Math.PI
const rawIndex = Math.round((angle - wheelGeometry.startAngle) / sliceAngle)
return (
((rawIndex % wheelItems.length) + wheelItems.length) % wheelItems.length
)
}
function preview(index: number, fromKeyboard = false) {
if (!canInteract.value) return
keyboardActive.value = fromKeyboard
activeIndex.value = index
}
function clearPreview() {
if (locked.value) return
activeIndex.value = null
keyboardActive.value = false
}
function onWheelPointerMove(event: PointerEvent) {
if (!canInteract.value) return
keyboardActive.value = false
activeIndex.value = getPointerIndex(event)
}
function onWheelClick(event: MouseEvent) {
if (!canInteract.value) return
const target = event.target
if (target instanceof Element && target.closest(".reaction-option")) return
const index = getPointerIndex(event)
if (index !== null) pick(wheelItems[index].key)
}
async function pick(key: ReactionKey) {
if (
!problem.value ||
!solved.value ||
locked.value ||
loading.value ||
submitting.value
)
return
activeIndex.value = null
keyboardActive.value = false
submitting.value = key
try {
const res = await setReaction(problem.value.id, key)
mine.value = res.data.mine
counts.value = res.data.counts
emit("submitted")
} catch {
message.error("提交失败,请重试")
} finally {
submitting.value = null
}
}
async function load(problemId: number) {
const sequence = ++loadSequence
loading.value = true
try {
const res = await getReaction(problemId)
if (sequence !== loadSequence) return
mine.value = res.data.mine
counts.value = res.data.counts
} catch {
if (sequence === loadSequence) message.error("暂时无法读取题目点评")
} finally {
if (sequence === loadSequence) loading.value = false
}
}
watch(
[() => userStore.isAuthed, () => problem.value?.id],
([isAuthed, problemId]) => {
mine.value = null
counts.value = null
submitting.value = null
activeIndex.value = null
keyboardActive.value = false
if (!isAuthed || problemId === undefined) {
loadSequence += 1
loading.value = false
return
}
load(problemId)
},
{ immediate: true },
)
</script>
<template>
<section class="reaction-panel" :style="reactionStyle" aria-label="题目点评">
<div class="wheel-stage">
<div
ref="wheelRef"
class="reaction-wheel"
:class="{
'has-selection': locked,
'is-disabled': !canInteract,
'is-keyboard-active': keyboardActive,
}"
role="group"
aria-label="选择一项题目点评点击后立即提交"
:aria-busy="loading || !!submitting"
@pointermove="onWheelPointerMove"
@pointerleave="clearPreview"
@click="onWheelClick"
>
<span
v-for="item in wheelItems"
:key="`${item.key}-face`"
class="segment-face"
:class="{
'is-active': activeIndex === item.index,
'is-selected': mine === item.key,
'is-submitting': submitting === item.key,
}"
:style="item.style"
aria-hidden="true"
/>
<span
v-for="item in wheelItems"
:key="`${item.key}-divider`"
class="segment-divider"
:style="item.style"
aria-hidden="true"
/>
<button
v-for="item in wheelItems"
:key="item.key"
type="button"
class="reaction-option"
:class="{
'is-active': activeIndex === item.index,
'is-selected': mine === item.key,
'is-submitting': submitting === item.key,
'is-muted': locked && mine !== item.key,
'is-unavailable': !userStore.isAuthed || !solved || loading,
}"
:style="item.style"
:disabled="!canInteract"
:aria-pressed="mine === item.key"
:aria-label="optionAriaLabel(item.key, item.label)"
@focus="preview(item.index, true)"
@blur="clearPreview"
@click.stop="pick(item.key)"
>
<span class="option-content">
<span class="option-icon" aria-hidden="true">
<Icon
:icon="
submitting === item.key
? 'svg-spinners:180-ring-with-bg'
: item.icon
"
/>
</span>
<span class="option-label">{{ item.label }}</span>
<span v-if="counts" class="option-count">
{{ counts[item.key] }}
</span>
</span>
</button>
<div class="wheel-core" aria-hidden="true">
<div class="core-content">
<Icon
class="core-icon"
:class="{ 'is-spinning': wheelCenter.spinning }"
:icon="wheelCenter.icon"
/>
<span class="core-eyebrow">{{ wheelCenter.eyebrow }}</span>
<strong class="core-label">{{ wheelCenter.label }}</strong>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.reaction-panel {
width: min(100%, 720px);
box-sizing: border-box;
container-type: inline-size;
margin: 0 auto;
padding: clamp(12px, 3vw, 24px);
color: var(--reaction-text);
}
.wheel-stage {
display: grid;
place-items: center;
padding: 4px 0;
}
.reaction-wheel {
position: relative;
width: min(100%, 400px);
box-sizing: border-box;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid var(--reaction-border);
border-radius: 50%;
background: var(--reaction-card);
isolation: isolate;
}
.segment-face {
position: absolute;
inset: 1px;
overflow: hidden;
background: color-mix(in srgb, var(--reaction-text) 4%, var(--reaction-card));
clip-path: var(--segment-path);
transform: translate(0, 0) scale(1);
transform-origin: center;
pointer-events: none;
transition:
transform 140ms cubic-bezier(0, 0, 0.2, 1),
background-color 140ms ease-out;
}
.segment-face:is(.is-active, .is-submitting) {
z-index: 2;
background: color-mix(
in srgb,
var(--reaction-accent) 10%,
var(--reaction-card)
);
transform: translate(calc(var(--push-x) * 0.7), calc(var(--push-y) * 0.7))
scale(1.045);
}
.segment-face.is-selected {
z-index: 3;
background: color-mix(
in srgb,
var(--reaction-accent) 18%,
var(--reaction-card)
);
transform: translate(var(--push-x), var(--push-y)) scale(1.075);
}
.segment-divider {
position: absolute;
z-index: 8;
top: 1px;
left: calc(50% - 1px);
width: 2px;
height: calc(50% - 1px);
background: var(--reaction-border);
transform: rotate(var(--divider-angle));
transform-origin: 50% 100%;
pointer-events: none;
}
.reaction-option {
position: absolute;
z-index: 9;
top: var(--content-y);
left: var(--content-x);
display: flex;
width: clamp(66px, 20%, 80px);
min-height: clamp(52px, 16%, 64px);
align-items: stretch;
justify-content: stretch;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--reaction-text);
font: inherit;
transform: translate(-50%, -50%);
touch-action: manipulation;
cursor: pointer;
}
.reaction-option:disabled {
cursor: not-allowed;
}
.reaction-option.is-unavailable {
opacity: 0.58;
}
.reaction-option.is-muted {
opacity: 0.48;
}
.reaction-option:is(.is-selected, .is-submitting) {
opacity: 1;
}
.reaction-option:focus-visible {
outline: 2px solid color-mix(in srgb, var(--reaction-accent) 55%, transparent);
outline-offset: 2px;
}
.option-content {
display: flex;
width: 100%;
min-height: 100%;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 2px;
border-radius: 6px;
transform: translate(0, 0) scale(1);
transition: transform 140ms cubic-bezier(0, 0, 0.2, 1);
}
.reaction-option:active:not(:disabled) .option-content {
transform: scale(0.96);
}
.reaction-option:is(.is-active, .is-submitting) .option-content {
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8))
scale(1.18);
}
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled)
.option-content {
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8))
scale(1.1);
}
.reaction-option.is-selected .option-content {
transform: translate(var(--push-x), var(--push-y)) scale(1.24);
}
.option-icon {
display: grid;
width: 28px;
height: 28px;
place-items: center;
font-size: 24px;
line-height: 1;
}
.option-icon svg {
width: 24px;
height: 24px;
}
.option-label {
font-size: 13px;
font-weight: 600;
line-height: 1.25;
white-space: nowrap;
}
.option-count {
color: var(--reaction-text-faint);
font-size: 12px;
font-weight: 400;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.reaction-option.is-selected .option-count {
color: var(--reaction-text);
}
.wheel-core {
position: absolute;
z-index: 12;
top: 50%;
left: 50%;
display: grid;
width: 31%;
aspect-ratio: 1;
place-items: center;
padding: 12px;
border: 1px solid var(--reaction-border);
border-radius: 50%;
background: var(--reaction-card);
color: var(--reaction-text);
text-align: center;
transform: translate(-50%, -50%);
pointer-events: none;
}
.core-content {
display: grid;
justify-items: center;
gap: 3px;
}
.core-icon {
width: clamp(20px, 5cqi, 26px);
height: clamp(20px, 5cqi, 26px);
font-size: clamp(20px, 5cqi, 26px);
color: var(--reaction-text-faint);
}
.core-eyebrow {
color: var(--reaction-text-faint);
font-size: clamp(9px, 2.3cqi, 11px);
font-weight: 400;
}
.core-label {
font-size: clamp(12px, 3cqi, 15px);
font-weight: 600;
line-height: 1.2;
text-wrap: balance;
}
.reaction-wheel.is-keyboard-active :is(.segment-face, .option-content) {
transition-duration: 0.01ms;
}
.is-spinning {
animation: reaction-spin 850ms linear infinite;
}
@container (max-width: 440px) {
.wheel-stage {
padding-top: 0;
}
.reaction-option {
width: clamp(58px, 20%, 70px);
min-height: clamp(48px, 16%, 56px);
}
.segment-face:is(.is-active, .is-submitting) {
transform: translate(calc(var(--push-x) * 0.5), calc(var(--push-y) * 0.5))
scale(1.03);
}
.segment-face.is-selected {
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75))
scale(1.05);
}
.option-content {
gap: 2px;
}
.reaction-option:is(.is-active, .is-submitting) .option-content {
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55))
scale(1.12);
}
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled)
.option-content {
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55))
scale(1.06);
}
.reaction-option.is-selected .option-content {
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75))
scale(1.17);
}
.option-icon {
width: 24px;
height: 24px;
font-size: 21px;
}
.option-icon svg {
width: 21px;
height: 21px;
}
.option-label {
font-size: 11px;
}
.option-count {
font-size: 11px;
}
.wheel-core {
padding: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.segment-face,
.option-content,
.option-icon,
.option-label,
.option-count {
transition-duration: 0.01ms;
}
.is-spinning {
animation-duration: 1.8s;
}
}
@keyframes reaction-spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
const theme = useThemeVars()
const props = defineProps<{
status: "not_test" | "passed" | "failed"
}>()
const showIcon = computed(() => props.status !== "not_test")
const color = computed(() => {
if (props.status === "passed") return theme.value.successColor
if (props.status === "failed") return theme.value.errorColor
})
</script>
<template>
<n-icon v-if="showIcon" :color="color">
<Icon icon="ph:check-bold" v-if="status === 'passed'"></Icon>
<Icon icon="ph:minus-bold" v-if="status === 'failed'"></Icon>
</n-icon>
</template>
<style scoped></style>

View File

@@ -0,0 +1,343 @@
<script lang="ts" setup>
import { NButton, NFlex, NTooltip } from "naive-ui"
import { Icon } from "@iconify/vue"
import { getSubmissions, getRankOfProblem } from "oj/api"
import Pagination from "shared/components/Pagination.vue"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useUserStore } from "shared/store/user"
import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
import { parseTime } from "utils/functions"
import { renderTableTitle } from "utils/renders"
import type { Submission } from "utils/types"
import SubmissionDetail from "oj/submission/detail.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
const { isDesktop } = useBreakpoints()
// 弹框状态管理
const [codePanelVisible, toggleCodePanel] = useToggle(false)
const submissionID = ref("")
const problemID = ref("")
// 显示代码弹框
function showCodePanel(id: string, problem: string) {
submissionID.value = id
problemID.value = problem
toggleCodePanel(true)
}
const columns: DataTableColumn<Submission>[] = [
{
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
key: "create_time",
width: 200,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: renderTableTitle("编号", "fluent-emoji-flat:input-numbers"),
key: "id",
minWidth: 160,
render: (row) => {
if (!row.show_link)
return h(NFlex, { align: "center" }, () => [
h("span", row.id.slice(0, 12)),
h(
NTooltip,
{},
{
trigger: () =>
h(NButton, { text: true }, () =>
h(Icon, { icon: "catppuccin:lock" }),
),
default: () =>
"这道题在你已经加入的题单中,只有在题单中完成此题,代码才可见。",
},
),
])
return h(
NButton,
{
text: true,
type: "info",
onClick: () => {
showCodePanel(row.id, (route.params.problemID as string) ?? "")
},
},
() => row.id.slice(0, 12),
)
},
},
{
title: renderTableTitle("状态", "streamline-emojis:panda-face"),
key: "status",
width: 140,
render: (row) => h(SubmissionResultTag, { result: row.result }),
},
{
title: renderTableTitle("语言", "streamline-ultimate-color:earth-pin-2"),
key: "language",
width: 100,
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
},
]
const class_name = ref("")
const rank = ref(-1)
const class_ac_count = ref(0)
const all_ac_count = ref(0)
const loading = ref(false)
const submissions = ref<Submission[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
})
// 错误分布统计
const statusDistribution = computed(() => {
if (!submissions.value.length) return []
const counts = new Map<number, number>()
for (const s of submissions.value) {
counts.set(s.result, (counts.get(s.result) || 0) + 1)
}
return Array.from(counts.entries())
.sort((a, b) => a[0] - b[0])
.map(([result, count]) => ({
result,
name: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name || "未知",
type: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.type || "info",
count,
}))
})
const errorMsg = computed(() => {
if (!userStore.isAuthed) return "请先登录"
else if (!userStore.showSubmissions) return "提交列表已被管理员关闭"
else return ""
})
async function listSubmissions() {
const offset = query.limit * (query.page - 1)
const res = await getSubmissions({
...query,
myself: "1",
offset,
problem_id: (route.params.problemID as string) ?? "",
contest_id: (route.params.contestID as string) ?? "",
})
submissions.value = res.data.results
total.value = res.data.total
}
async function getRankOfThisProblem() {
loading.value = true
const res = await getRankOfProblem((route.params.problemID as string) ?? "")
loading.value = false
class_name.value = res.data.class_name
rank.value = res.data.rank
class_ac_count.value = res.data.class_ac_count
all_ac_count.value = res.data.all_ac_count
}
onMounted(() => {
listSubmissions()
if (route.name === "problem") {
getRankOfThisProblem()
}
})
watch(query, listSubmissions)
</script>
<template>
<n-alert
class="tip"
type="error"
v-if="!userStore.showSubmissions || !userStore.isAuthed"
:title="errorMsg"
/>
<template v-if="!loading && route.name === 'problem' && userStore.isAuthed">
<template v-if="class_name">
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
<template #header>
<n-flex align="center">
<span>
本道题你在班上排名第 <b>{{ rank }}</b
>你们班共有 <b>{{ class_ac_count }}</b> 人答案正确
</span>
<n-button
secondary
v-if="userStore.showSubmissions"
@click="
router.push({
name: 'submissions',
query: {
problem: route.params.problemID,
result: '0',
page: 1,
limit: 10,
username: 'ks' + class_name,
},
})
"
>
查看
</n-button>
</n-flex>
</template>
</n-alert>
<n-alert
class="tip"
type="error"
:show-icon="false"
v-if="rank === -1 && class_ac_count > 0"
>
<template #header>
<n-flex align="center">
<span>
本道题你还没有解决你们班共有
<b>{{ class_ac_count }}</b> 人答案正确
</span>
<n-button
v-if="userStore.showSubmissions"
secondary
@click="
router.push({
name: 'submissions',
query: {
problem: route.params.problemID,
result: '0',
page: 1,
limit: 10,
username: 'ks' + class_name,
},
})
"
>
查看
</n-button>
</n-flex>
</template>
</n-alert>
</template>
<template v-else>
<n-alert class="tip" type="success" :show-icon="false" v-if="rank !== -1">
<template #header>
<n-flex align="center">
<span>
本道题你在全服排名第 <b>{{ rank }}</b
>全服共有 <b>{{ all_ac_count }}</b> 人答案正确
</span>
<n-button
secondary
v-if="userStore.showSubmissions"
@click="
router.push({
name: 'submissions',
query: {
problem: route.params.problemID,
result: '0',
page: 1,
limit: 10,
},
})
"
>
查看
</n-button>
</n-flex>
</template>
</n-alert>
<n-alert
class="tip"
type="error"
:show-icon="false"
v-if="rank === -1 && all_ac_count > 0"
>
<template #header>
<n-flex align="center">
<span>
本道题你还没有解决全服共有 <b>{{ all_ac_count }}</b> 人答案正确
</span>
<n-button
v-if="userStore.showSubmissions"
secondary
@click="
router.push({
name: 'submissions',
query: {
problem: route.params.problemID,
result: '0',
page: 1,
limit: 10,
},
})
"
>
查看
</n-button>
</n-flex>
</template>
</n-alert>
</template>
</template>
<template v-if="userStore.showSubmissions && userStore.isAuthed">
<!-- 错误分布统计 -->
<n-flex
v-if="statusDistribution.length"
class="tip"
align="center"
:wrap="true"
>
<span style="font-weight: bold; font-size: 13px">我的提交统计</span>
<n-tag
v-for="item in statusDistribution"
:key="item.result"
:type="item.type as any"
size="small"
round
>
{{ item.name }} × {{ item.count }}
</n-tag>
</n-flex>
<n-data-table
v-if="submissions.length > 0"
striped
:columns="columns"
:data="submissions"
/>
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</template>
<!-- 代码详情弹框 -->
<n-modal
v-model:show="codePanelVisible"
preset="card"
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
title="代码详情"
>
<SubmissionDetail
:problemID="problemID"
:submissionID="submissionID"
hideList
@copied="toggleCodePanel(false)"
/>
</n-modal>
</template>
<style scoped>
.tip {
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<div class="yearly-chart" v-if="props.data.length > 1">
<Line :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup lang="ts">
import { Line } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
Filler,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
} from "chart.js"
import type { YearlyACData } from "oj/api"
ChartJS.register(
CategoryScale,
Filler,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
)
const props = defineProps<{ data: YearlyACData[] }>()
const chartData = computed(() => ({
labels: props.data.map((d) => String(d.year)),
datasets: [
{
label: "AC 率",
data: props.data.map((d) => d.ac_rate),
fill: true,
tension: 0.3,
backgroundColor: "rgba(99, 179, 237, 0.2)",
borderColor: "rgba(99, 179, 237, 1)",
pointBackgroundColor: "rgba(99, 179, 237, 1)",
},
],
}))
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: "历年 AC 率",
font: { size: 20 },
},
tooltip: {
callbacks: {
label: (context: any) => {
const d = props.data[context.dataIndex]
return [`AC 率: ${d.ac_rate}%`, `通过: ${d.accepted} / ${d.total}`]
},
},
},
},
scales: {
y: {
min: 0,
max: 100,
ticks: {
callback: (value: any) => `${value}%`,
},
},
},
}))
</script>
<style scoped>
.yearly-chart {
width: 100%;
max-width: 500px;
height: 250px;
margin: 24px auto;
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { SQLDisplayColumn } from "utils/types"
defineProps<{
columns: SQLDisplayColumn[]
rows: (string | number | null)[][]
totalRows?: number
truncated?: boolean
}>()
</script>
<template>
<n-table class="sqlTable" size="small" :single-line="false">
<thead>
<tr>
<th v-for="(col, i) in columns" :key="i">
{{ col.name }}
<span v-if="col.type" class="colType">{{ col.type }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-if="rows.length === 0">
<td :colspan="columns.length" class="nullCell">空表</td>
</tr>
<tr v-for="(row, i) in rows" :key="i">
<td v-for="(v, j) in row" :key="j" :class="{ nullCell: v === null }">
{{ v === null ? "NULL" : v }}
</td>
</tr>
</tbody>
</n-table>
<p v-if="truncated" class="truncNote">
{{ totalRows }} 仅展示前 {{ rows.length }}
</p>
</template>
<style scoped>
.sqlTable {
margin-bottom: 8px;
}
.colType {
font-size: 12px;
opacity: 0.55;
margin-left: 4px;
font-weight: normal;
}
.nullCell {
opacity: 0.45;
font-style: italic;
}
.truncNote {
font-size: 13px;
opacity: 0.65;
margin: 0 0 8px;
}
</style>

View File

@@ -0,0 +1,237 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import {
getCSRFToken,
submissionMemoryFormat,
submissionTimeFormat,
} from "utils/functions"
import type { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useProblemStore } from "oj/store/problem"
import { consumeJSONEventStream } from "utils/stream"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { useDark } from "@vueuse/core"
const props = defineProps<{
submission?: Submission
}>()
const isDark = useDark()
const problemStore = useProblemStore()
const theme = useThemeVars()
// AI 提示状态
const hintContent = ref("")
const hintLoading = ref(false)
const hintError = ref("")
// 错误信息格式化
const msg = computed(() => {
if (!props.submission) return ""
let msg = ""
const result = props.submission.result
// 编译错误或运行时错误时给出提示;
// SQL 题的运行错误多半是"查询题里写了增删改"这类被判题拒绝的语句err_info 已说明原因,不套这句
if (
(result === SubmissionStatus.compile_error ||
result === SubmissionStatus.runtime_error) &&
props.submission.language !== "SQL"
) {
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
}
if (
result !== SubmissionStatus.ast_check_failed &&
props.submission.statistic_info?.err_info
) {
msg += props.submission.statistic_info.err_info
}
return msg
})
// 是否显示AI提示区域
const showAIHint = computed(() => {
if (!props.submission) return false
return (
problemStore.failCount >= 3 &&
props.submission.result !== SubmissionStatus.accepted &&
props.submission.result !== SubmissionStatus.ast_check_failed &&
props.submission.result !== SubmissionStatus.pending &&
props.submission.result !== SubmissionStatus.judging &&
props.submission.result !== SubmissionStatus.submitting
)
})
async function fetchHint(submissionId: string) {
hintLoading.value = true
hintContent.value = ""
hintError.value = ""
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
const csrfToken = getCSRFToken()
if (csrfToken) {
headers["X-CSRFToken"] = csrfToken
}
const response = await fetch("/api/ai/hint", {
method: "POST",
headers,
body: JSON.stringify({ submission_id: submissionId }),
})
await consumeJSONEventStream(response, {
onMessage: (data: {
type: string
content?: string
message?: string
}) => {
if (data.type === "delta" && data.content) {
hintContent.value += data.content
} else if (data.type === "error") {
hintError.value = data.message || "AI 提示生成失败"
}
},
})
} catch (e: any) {
hintError.value = e.message || "请求失败"
} finally {
hintLoading.value = false
}
}
// 测试用例表格数据(只在部分通过时显示)
const infoTable = computed(() => {
if (!props.submission?.info?.data?.length) return []
const result = props.submission.result
// AC、编译错误、运行时错误不显示测试用例表格
if (
result === SubmissionStatus.accepted ||
result === SubmissionStatus.ast_check_failed ||
result === SubmissionStatus.compile_error ||
result === SubmissionStatus.runtime_error
) {
return []
}
const data = props.submission.info.data
// 只有存在失败的测试用例时才显示
return data.some((item) => item.result === 0) ? data : []
})
// 测试用例表格列配置
const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
{ title: "测试用例", key: "test_case" },
{
title: "测试状态",
key: "result",
render: (row) => h(SubmissionResultTag, { result: row.result }),
},
{
title: "占用内存",
key: "memory",
render: (row) => submissionMemoryFormat(row.memory),
},
{
title: "执行耗时",
key: "real_time",
render: (row) => submissionTimeFormat(row.real_time),
},
{ title: "信号", key: "signal" },
]
</script>
<template>
<div v-if="submission">
<n-alert
:type="JUDGE_STATUS[submission.result]['type']"
:title="JUDGE_STATUS[submission.result]['title']"
class="mb-3"
/>
<n-flex
vertical
v-if="
msg ||
infoTable.length ||
submission.statistic_info?.ast_results?.length
"
>
<n-card v-if="submission.statistic_info?.ast_results?.length" embedded>
<n-flex vertical :size="8">
<n-flex
v-for="(rule, i) in submission.statistic_info.ast_results"
:key="i"
align="center"
:size="6"
>
<n-icon
:color="rule.passed ? theme.successColor : theme.errorColor"
>
<Icon :icon="rule.passed ? 'ph:check-bold' : 'ph:x-bold'" />
</n-icon>
<span>{{ rule.description }}</span>
</n-flex>
</n-flex>
</n-card>
<n-card v-if="msg" embedded class="msg">{{ msg }}</n-card>
<n-data-table
v-if="infoTable.length"
striped
:data="infoTable"
:columns="columns"
/>
</n-flex>
<!-- AI 提示区域 -->
<template v-if="showAIHint">
<n-card size="small" style="margin-top: 12px; max-width: 480px">
<n-alert
v-if="hintError"
type="error"
:title="hintError"
class="mb-3"
/>
<n-button
v-if="!hintContent && !hintLoading"
type="primary"
@click="fetchHint(submission.id)"
>
让 AI 分析我的代码
</n-button>
<n-spin v-else-if="hintLoading && !hintContent" size="small" />
<MdPreview
v-if="hintContent"
:model-value="hintContent"
preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'"
/>
</n-card>
</template>
</div>
</template>
<style scoped>
.msg {
white-space: pre;
word-break: break-all;
line-height: 1.5;
}
.gradient-text {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,275 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia"
import {
formatCode,
getReaction,
submitCode,
updateProblemSetProgress,
} from "oj/api"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { useFireworks } from "oj/problem/composables/useFireworks"
import { useSubmissionMonitor } from "oj/problem/composables/useSubmissionMonitor"
import { LANGUAGE_FORMAT_VALUE, SubmissionStatus } from "utils/constants"
import type { SubmitCodePayload } from "utils/types"
import SubmissionResult from "./SubmissionResult.vue"
import { getSubmitButtonState } from "./submitButtonState"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import {
checkPythonSyntax,
prefetchPythonSyntaxChecker,
} from "oj/problem/utils/pythonSyntaxCheck"
// ==================== 异步组件 ====================
const ProblemReaction = defineAsyncComponent(
() => import("./ProblemReaction.vue"),
)
// ==================== 基础状态 ====================
const userStore = useUserStore()
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const route = useRoute()
const contestID = (route.params.contestID as string) ?? ""
const problemSetId = (route.params.problemSetId as string) ?? ""
const router = useRouter()
const [commentPanel] = useToggle()
const message = useMessage()
function closeCommentPanel() {
commentPanel.value = false
}
const { isDesktop } = useBreakpoints()
// ==================== 烟花效果 ====================
const { celebrate } = useFireworks()
// ==================== 判题监控 ====================
const { submission, judging, pending, submitting, startMonitoring } =
useSubmissionMonitor()
const showResult = ref(false)
const isFormatting = ref(false)
const isSubmittingRequest = ref(false)
// ==================== Python 语法检测器预取 ====================
// 选中 Python3 时就把 Skulpt 拉下来,避免点提交时才开始下载
watch(
() => codeStore.code.language,
(language) => {
if (language === "Python3") prefetchPythonSyntaxChecker()
},
{ immediate: true },
)
// ==================== 提交冷却 ====================
const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
controls: true,
immediate: false,
})
// ==================== AC后显示评论框 ====================
const { start: showCommentPanelDelayed } = useTimeoutFn(
async () => {
const res = await getReaction(problem.value!.id)
if (res.data.mine === null) {
commentPanel.value = true
}
},
1500,
{ immediate: false },
)
const { start: goToProblemSetDelayed } = useTimeoutFn(
() => {
router.push({
name: "problemset",
params: {
problemSetId: problemSetId,
},
})
},
1500,
{ immediate: false },
)
// ==================== 计算属性 ====================
const buttonState = computed(() =>
getSubmitButtonState({
isAuthed: userStore.isAuthed,
hasCode: codeStore.code.value.trim() !== "",
isFormatting: isFormatting.value,
isSubmitting: isSubmittingRequest.value || submitting.value,
isJudging: judging.value || pending.value,
isCooldown: isCooldown.value,
}),
)
// ==================== 提交函数 ====================
async function submit() {
if (buttonState.value.disabled) return
// 0. Python3 语法检测
if (codeStore.code.language === "Python3") {
const syntaxError = await checkPythonSyntax(codeStore.code.value)
if (syntaxError) {
message.warning(`${syntaxError.line} 行存在语法错误,请修正后再提交`)
return
}
}
// 0.5 提交前自动格式化Python3 用 ruffC/C++ 用 clang-formatSQL 用 sqlparse
const formatLang = LANGUAGE_FORMAT_VALUE[codeStore.code.language]
if (["python", "c", "cpp", "sql"].includes(formatLang)) {
isFormatting.value = true
try {
const res = await formatCode({
code: codeStore.code.value,
language: formatLang,
})
codeStore.setCode(res.data.code)
} catch (e: any) {
if (e?.error === "format-error") {
// 仅 Python3 会出现:代码本身存在语法错误
message.warning(`代码格式化失败:${e.data},请检查代码后重试`)
return
}
// server-error / 网络异常:格式化工具问题,静默降级,提交原代码
} finally {
isFormatting.value = false
}
}
// 1. 构建提交数据
const data: SubmitCodePayload = {
problem_id: problem.value!.id,
language: codeStore.code.language,
code: codeStore.code.value,
}
if (contestID) {
data.contest_id = parseInt(contestID)
}
// 2. 提交代码到后端
isSubmittingRequest.value = true
try {
const res = await submitCode(data)
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`)
// 3. 启动冷却 + 监控
startCooldown()
startMonitoring(res.data.submission_id)
showResult.value = true
} finally {
isSubmittingRequest.value = false
}
}
// ==================== 失败计数 ====================
watch(
() => submission.value?.result,
(result) => {
if (result === undefined || result === null) return
if (
result === SubmissionStatus.pending ||
result === SubmissionStatus.judging ||
result === SubmissionStatus.submitting
)
return
if (
result !== SubmissionStatus.accepted &&
result !== SubmissionStatus.ast_check_failed
) {
problemStore.incrementFailCount()
}
},
)
// ==================== AC庆祝效果 ====================
watch(
() => submission.value?.result,
async (result) => {
if (
result !== SubmissionStatus.accepted &&
result !== SubmissionStatus.ast_check_failed
)
return
// 1. 刷新题目状态
problem.value!.my_status = 0
// 2. 创建ProblemSetSubmission记录更新题单进度
if (problemSetId) {
await updateProblemSetProgress(
Number(problemSetId),
problem.value!.id,
submission.value!.id,
)
}
if (result !== SubmissionStatus.accepted) return
// 3. 放烟花
celebrate()
// 4. 显示评价框
if (!contestID && !problemSetId) {
showCommentPanelDelayed()
}
if (problemSetId) {
// 延迟回到题单页面
goToProblemSetDelayed()
}
},
)
</script>
<template>
<!-- 提交按钮 + 结果弹窗 -->
<n-popover
trigger="manual"
placement="bottom-end"
scrollable
:show-arrow="false"
style="max-height: 600px"
:show="showResult"
@clickoutside="showResult = false"
>
<template #trigger>
<n-button
:size="isDesktop ? 'medium' : 'small'"
type="primary"
:disabled="buttonState.disabled"
@click="submit"
>
<template #icon>
<n-icon>
<Icon :icon="buttonState.icon" />
</n-icon>
</template>
{{ buttonState.label }}
</n-button>
</template>
<!-- 结果展示 -->
<SubmissionResult :submission="submission" />
</n-popover>
<!-- 评价弹窗 -->
<n-modal
preset="card"
title="恭喜你成功提交,说说你对这道题的感受吧"
:mask-closable="false"
:closable="false"
:close-on-esc="false"
:style="{ maxWidth: isDesktop && '50vw', maxHeight: '80vh' }"
v-model:show="commentPanel"
>
<ProblemReaction @submitted="closeCommentPanel" />
</n-modal>
</template>

View File

@@ -0,0 +1,422 @@
<script lang="ts" setup>
import { toRefs } from "vue"
// 工具函数
import { atou, utoa } from "utils/functions"
// 组合式函数
import { useBreakpoints } from "shared/composables/breakpoints"
import { useMermaid } from "shared/composables/useMermaid"
import { useMermaidConverter } from "../composables/useMermaidConverter"
import {
useFlowchartWebSocket,
type FlowchartEvaluationUpdate,
} from "shared/composables/websocket"
import { useMyFlowchartStore } from "shared/store/myFlowchart"
// API 和状态管理
import {
getCurrentProblemFlowchartSubmission,
getFlowchartSubmissionDetail,
submitFlowchart,
} from "oj/api"
import { useProblemStore } from "oj/store/problem"
// ==================== 类型定义 ====================
interface Rating {
score: number
grade: string
}
interface Evaluation extends Rating {
feedback: string
suggestions: string
criteria_details: {
[key: string]: { score: number; max: number; comment: string }
}
}
// ==================== 组合式函数和响应式变量 ====================
interface FlowchartEditorInstance {
getFlowchartData: () => { nodes: unknown[]; edges: unknown[] }
setFlowchartData: (data: { nodes: unknown[]; edges: unknown[] }) => void
}
// 通过inject获取FlowchartEditor组件的引用
const flowchartEditorRef =
inject<Ref<FlowchartEditorInstance | null>>("flowchartEditorRef")
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
// 基础组合式函数
const message = useMessage()
const problemStore = useProblemStore()
const { problem } = toRefs(problemStore)
const { isDesktop } = useBreakpoints()
const myFlowchartStore = useMyFlowchartStore()
const { convertToMermaid } = useMermaidConverter()
const { renderError, renderFlowchart } = useMermaid()
// 状态管理
const rendering = ref(false)
const loading = ref(false)
const latestRating = ref<Rating>({ score: 0, grade: "" })
const modalRating = ref<Rating>({ score: 0, grade: "" })
const submissionCount = ref(0)
const myFlowchartZippedStr = ref("")
const myMermaidCode = ref("")
const showDetailModal = ref(false)
const evaluation = ref<Evaluation>({
score: 0,
grade: "",
feedback: "",
suggestions: "",
criteria_details: {},
})
const page = ref(1)
const lastSubmittedMermaidCode = ref("")
const suggestionLines = computed(() =>
splitSuggestionLines(evaluation.value.suggestions),
)
function splitSuggestionLines(suggestions?: string | null) {
return suggestions
? suggestions
.split("\n")
.map((suggestion) => suggestion.trim())
.filter(Boolean)
: []
}
// ==================== WebSocket 相关函数 ====================
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
if (data.type === "flowchart_evaluation_completed") {
loading.value = false
const grade = data.grade || ""
latestRating.value = { score: data.score || 0, grade }
message.success(`流程图评分完成!得分: ${data.score}分 (${grade}级)`)
if ((grade === "A" || grade === "S") && lastSubmittedMermaidCode.value) {
myFlowchartStore.show(lastSubmittedMermaidCode.value)
}
} else if (data.type === "flowchart_evaluation_failed") {
loading.value = false
message.error(`流程图评分失败: ${data.error}`)
}
}
// 创建 WebSocket 连接
const { connect, disconnect, subscribe } = useFlowchartWebSocket(
handleWebSocketMessage,
)
// 订阅提交更新
function subscribeToSubmission(submissionId: string) {
subscribe(submissionId)
}
// ==================== 提交相关函数 ====================
// 提交流程图
async function submitFlowchartData() {
if (!flowchartEditorRef?.value) return
// 获取流程图的JSON数据
const flowchartData = flowchartEditorRef.value.getFlowchartData()
if (!flowchartData?.nodes?.length || !flowchartData?.edges?.length) {
message.error("流程图节点或边不能为空")
return
}
const mermaidCode = convertToMermaid(flowchartData)
lastSubmittedMermaidCode.value = mermaidCode
const compressed = utoa(JSON.stringify(flowchartData))
loading.value = true
latestRating.value = { score: 0, grade: "" }
try {
const response = await submitFlowchart({
problem_id: problem.value!.id,
mermaid_code: mermaidCode,
flowchart_data: {
compressed: true,
data: compressed,
},
})
// 获取提交ID并订阅更新
const submissionId = response.data.submission_id
if (submissionId) {
subscribeToSubmission(submissionId)
}
message.success("流程图已提交,请耐心等待评分")
} catch (error) {
loading.value = false
message.error("流程图提交失败")
console.error("提交流程图失败:", error)
}
}
// 提交函数
function submit() {
submitFlowchartData()
}
// ==================== 数据获取和处理函数 ====================
async function getCurrentSubmission() {
if (!problem.value?.id) return
const { data } = await getCurrentProblemFlowchartSubmission(problem.value.id)
submissionCount.value = data.count
latestRating.value = {
score: data.score,
grade: data.grade,
}
}
async function getSubmission(submissionPage = 0) {
if (!problem.value?.id) return
const { data } = await getFlowchartSubmissionDetail(
problem.value.id,
submissionPage,
)
submissionCount.value = data.count
const submission = data.submission
myFlowchartZippedStr.value = submission.flowchart_data.data
myMermaidCode.value = submission.mermaid_code || ""
modalRating.value = {
score: submission.ai_score,
grade: submission.ai_grade,
}
evaluation.value = {
score: submission.ai_score,
grade: submission.ai_grade,
feedback: submission.ai_feedback,
suggestions: submission.ai_suggestions,
criteria_details: submission.ai_criteria_details,
}
}
async function updatePage(val: number) {
page.value = val
rendering.value = true
await getSubmission(val)
// 等待 DOM 更新
await nextTick()
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
rendering.value = false
}
// ==================== 模态框相关函数 ====================
async function openDetailModal() {
showDetailModal.value = true
rendering.value = true
await getSubmission()
page.value = submissionCount.value
// 等待 DOM 更新,确保弹框已经渲染
await nextTick()
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
rendering.value = false
}
function closeModal() {
showDetailModal.value = false
}
function loadToEditor() {
if (myFlowchartZippedStr.value) {
const str = atou(myFlowchartZippedStr.value)
const json = JSON.parse(str)
const processedData = {
nodes: json.nodes || [],
edges: json.edges || [],
}
if (flowchartEditorRef?.value) {
flowchartEditorRef.value.setFlowchartData(processedData)
}
}
closeModal()
}
// ==================== 工具函数 ====================
const getGradeType = (grade: string) => {
if (grade === "S") return "primary"
if (grade === "A") return "info"
if (grade === "B") return "warning"
return "error"
}
const getPercentType = (percent: number) => {
if (percent >= 0.8) return "primary"
else if (percent >= 0.6) return "info"
else if (percent >= 0.4) return "warning"
return "error"
}
// ==================== 生命周期钩子 ====================
onMounted(async () => {
connect()
await getCurrentSubmission()
page.value = submissionCount.value
const grade = latestRating.value.grade
if ((grade === "A" || grade === "S") && submissionCount.value > 0) {
await getSubmission(submissionCount.value)
if (myMermaidCode.value) {
myFlowchartStore.show(myMermaidCode.value)
}
}
})
// 组件卸载时断开连接
onUnmounted(() => {
disconnect()
})
</script>
<template>
<!-- 主要操作区域 -->
<n-flex align="center">
<!-- 提交按钮 -->
<n-button
:size="isDesktop ? 'medium' : 'small'"
type="primary"
:loading="loading"
:disabled="loading"
@click="submit"
>
{{ loading ? "AI 点评中..." : "提交流程图" }}
</n-button>
<!-- 评分结果按钮 -->
<n-button
secondary
v-if="latestRating.grade"
@click="openDetailModal"
:type="getGradeType(latestRating.grade)"
>
{{ latestRating.score }} {{ latestRating.grade }}
</n-button>
<!-- 流程图评分详情模态框 -->
<n-modal v-model:show="showDetailModal" preset="card" style="width: 1000px">
<template #header>
<n-flex align="center">
<n-text>流程图评分详情</n-text>
<n-text :type="getGradeType(modalRating.grade)">
{{ modalRating.score }} {{ modalRating.grade }}
</n-text>
</n-flex>
</template>
<n-grid :cols="5" :x-gap="16">
<!-- 左侧流程图预览区域 -->
<n-gi :span="3">
<div class="flowchart">
<n-spin :show="rendering">
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
{{ renderError }}
</n-alert>
<div class="flowchart" v-else ref="mermaidContainer"></div>
</n-spin>
</div>
<!-- 加载到编辑器按钮 -->
<n-flex style="margin-top: 16px" justify="center">
<n-button @click="loadToEditor" type="primary">
加载到流程图编辑器
</n-button>
</n-flex>
</n-gi>
<!-- 右侧评分详情区域 -->
<n-gi :span="2" style="max-height: 550px; overflow: auto">
<!-- AI反馈 -->
<n-card
v-if="evaluation.feedback"
size="small"
title="AI反馈"
style="margin-bottom: 16px"
>
<n-text>{{ evaluation.feedback }}</n-text>
</n-card>
<!-- 改进建议 -->
<n-card
v-if="suggestionLines.length"
size="small"
title="改进建议"
style="margin-bottom: 16px"
>
<n-flex vertical :size="6">
<n-text
v-for="(suggestion, index) in suggestionLines"
:key="`${index}-${suggestion}`"
>
{{ suggestion }}
</n-text>
</n-flex>
</n-card>
<!-- 详细评分 -->
<n-card
v-if="evaluation.criteria_details"
size="small"
title="详细评分"
>
<div
v-for="(detail, key) in evaluation.criteria_details"
:key="key"
style="margin-bottom: 12px"
>
<!-- 评分项标题和分数 -->
<n-flex
justify="space-between"
align="center"
style="margin-bottom: 4px"
>
<n-text strong>{{ key }}</n-text>
<n-tag
:type="getPercentType(detail.score / detail.max)"
size="small"
round
>
{{ detail.score || 0 }} / {{ detail.max }}
</n-tag>
</n-flex>
<!-- 评分项详细说明 -->
<n-text v-if="detail.comment" depth="3" style="font-size: 12px">
{{ detail.comment }}
</n-text>
</div>
</n-card>
</n-gi>
</n-grid>
<!-- 分页组件 -->
<n-flex
justify="center"
style="margin-top: 24px"
v-if="submissionCount > 1"
>
<n-pagination
v-model:page="page"
:page-count="submissionCount"
@update-page="updatePage"
/>
</n-flex>
</n-modal>
</n-flex>
</template>
<style scoped>
/* ==================== 流程图样式 ==================== */
.flowchart {
height: 500px;
display: flex;
justify-content: center;
align-items: center;
}
/* 确保 SVG 图表占满容器 */
:deep(.flowchart > svg) {
height: 100%;
}
</style>

View File

@@ -0,0 +1,53 @@
export interface SubmitButtonStateInput {
isAuthed: boolean
hasCode: boolean
isFormatting: boolean
isSubmitting: boolean
isJudging: boolean
isCooldown: boolean
}
export interface SubmitButtonState {
disabled: boolean
label: string
icon: string
}
export function getSubmitButtonState({
isAuthed,
hasCode,
isFormatting,
isSubmitting,
isJudging,
isCooldown,
}: SubmitButtonStateInput): SubmitButtonState {
const disabled =
!isAuthed ||
!hasCode ||
isFormatting ||
isSubmitting ||
isJudging ||
isCooldown
let label = "提交代码"
if (!isAuthed) {
label = "请先登录"
} else if (isFormatting) {
label = "格式化中"
} else if (isSubmitting) {
label = "正在提交"
} else if (isJudging) {
label = "正在评分"
} else if (isCooldown) {
label = "正在冷却"
}
const icon =
isFormatting || isSubmitting || isJudging
? "eos-icons:loading"
: isCooldown
? "ph:lightbulb-fill"
: "ph:play-fill"
return { disabled, label, icon }
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,125 @@
/**
* 将流程图JSON数据转换为Mermaid格式
*/
export function useMermaidConverter() {
const convertToMermaid = (flowchartData: any) => {
const { nodes, edges } = flowchartData
if (!nodes || nodes.length === 0) {
return "graph TD\n A[空流程图]"
}
let mermaid = "graph TD\n"
// Build safe ID mapping to prevent Mermaid syntax errors from special characters
const idMap = new Map<string, string>()
nodes.forEach((node: any, index: number) => {
idMap.set(node.id, `node_${index}`)
})
const safeId = (id: string) =>
idMap.get(id) || id.replace(/[^a-zA-Z0-9_]/g, "_")
// 处理节点 - 根据原始类型和自定义标签
nodes.forEach((node: any) => {
const nodeId = safeId(node.id)
const label = node.data?.customLabel || node.data?.label || "节点"
const originalType = node.data?.originalType || node.type
// 根据节点原始类型确定Mermaid语法
switch (originalType) {
case "start":
mermaid += ` ${nodeId}(("${label}"))\n`
break
case "end":
mermaid += ` ${nodeId}(("${label}"))\n`
break
case "input":
// 输入框使用平行四边形
mermaid += ` ${nodeId}[/"${label}"/]\n`
break
case "output":
// 输出框使用平行四边形
mermaid += ` ${nodeId}[/"${label}"/]\n`
break
case "default":
mermaid += ` ${nodeId}["${label}"]\n`
break
case "decision":
mermaid += ` ${nodeId}{"${label}"}\n`
break
case "loop":
// 循环使用菱形
mermaid += ` ${nodeId}{"${label}"}\n`
break
default:
mermaid += ` ${nodeId}["${label}"]\n`
}
})
// 处理边
edges.forEach((edge: any) => {
const source = safeId(edge.source)
const target = safeId(edge.target)
const label = edge.label ?? ""
if (label && label.trim() !== "") {
mermaid += ` ${source} -->|"${label}"| ${target}\n`
} else {
mermaid += ` ${source} --> ${target}\n`
}
})
// 添加样式定义来区分不同类型的节点
mermaid += "\n"
mermaid +=
" classDef startNode fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#0f172a\n"
mermaid +=
" classDef endNode fill:#fee2e2,stroke:#dc2626,stroke-width:2px,color:#0f172a\n"
mermaid +=
" classDef input fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#0f172a\n"
mermaid +=
" classDef output fill:#ede9fe,stroke:#7c3aed,stroke-width:2px,color:#0f172a\n"
mermaid +=
" classDef process fill:#f0f9ff,stroke:#0284c7,stroke-width:2px,color:#0f172a\n"
mermaid +=
" classDef decision fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#0f172a\n"
mermaid +=
" classDef loop fill:#fae8ff,stroke:#c026d3,stroke-width:2px,color:#0f172a\n"
mermaid += "\n"
// 为节点应用样式
nodes.forEach((node: any) => {
const nodeId = safeId(node.id)
const originalType = node.data?.originalType || node.type
switch (originalType) {
case "start":
mermaid += ` class ${nodeId} startNode\n`
break
case "end":
mermaid += ` class ${nodeId} endNode\n`
break
case "input":
mermaid += ` class ${nodeId} input\n`
break
case "output":
mermaid += ` class ${nodeId} output\n`
break
case "decision":
mermaid += ` class ${nodeId} decision\n`
break
case "loop":
mermaid += ` class ${nodeId} loop\n`
break
default:
mermaid += ` class ${nodeId} process\n`
}
})
return mermaid
}
return {
convertToMermaid,
}
}

View File

@@ -0,0 +1,190 @@
import { ref, computed, watch, onUnmounted } from "vue"
import { useIntervalFn, useTimeoutFn } from "@vueuse/core"
import { getSubmission } from "oj/api"
import { SubmissionStatus } from "utils/constants"
import type { PendingAchievement, Submission } from "utils/types"
import { useAchievementStore } from "shared/store/achievement"
import {
useSubmissionWebSocket,
type SubmissionUpdate,
} from "shared/composables/websocket"
/**
* 判题监控 Composable
* 负责通过 WebSocket + 轮询双保险机制监控判题结果
*/
export function useSubmissionMonitor() {
// ==================== 状态 ====================
const submissionId = ref("")
const submission = ref<Submission>()
// ==================== 轮询机制 ====================
const { pause: pausePolling, resume: resumePolling } = useIntervalFn(
async () => {
if (!submissionId.value) return
try {
const res = await getSubmission(submissionId.value)
submission.value = res.data
const result = res.data.result
// 判题完成,停止轮询
if (
result !== SubmissionStatus.judging &&
result !== SubmissionStatus.pending
) {
pausePolling()
}
} catch (error) {
console.error("[SubmissionMonitor] 轮询失败:", error)
pausePolling()
}
},
2000,
{ immediate: false },
)
// ==================== WebSocket 处理 ====================
const handleSubmissionUpdate = (data: SubmissionUpdate) => {
// push_to_user 复用了 submission_update 这个 channel handler
// 其他类型的消息会走同一条 WebSocket 帧进来,必须先分流
const frame = data as unknown as {
type: string
achievements?: PendingAchievement[]
}
if (frame.type === "achievement_unlocked") {
useAchievementStore().enqueue(frame.achievements ?? [])
return
}
if (frame.type !== "submission_update") {
return
}
console.log("[SubmissionMonitor] 收到WebSocket更新:", data)
if (data.submission_id !== submissionId.value) {
console.log("[SubmissionMonitor] 提交ID不匹配忽略")
return
}
if (!submission.value) {
submission.value = {} as Submission
}
submission.value.result = data.result as Submission["result"]
// 判题完成或出错,获取完整详情
if (data.status === "finished" || data.status === "error") {
console.log(
`[SubmissionMonitor] 判题${data.status === "finished" ? "完成" : "出错"}`,
)
// 停止轮询WebSocket已成功
pausePolling()
getSubmission(submissionId.value).then((res) => {
submission.value = res.data
// 15分钟无新提交则断开WebSocket节省资源
scheduleDisconnect(15 * 60 * 1000)
})
}
}
// 初始化 WebSocket
const {
connect,
subscribe,
scheduleDisconnect,
cancelScheduledDisconnect,
status: wsStatus,
} = useSubmissionWebSocket(handleSubmissionUpdate)
// ==================== 轮询保底启动 ====================
const { start: startPollingFallback } = useTimeoutFn(
() => {
if (
submission.value &&
(submission.value.result === SubmissionStatus.judging ||
submission.value.result === SubmissionStatus.pending ||
submission.value.result === 9) // 9 = submitting
) {
console.log("[SubmissionMonitor] WebSocket未及时响应启动轮询保底")
resumePolling()
}
},
5000,
{ immediate: false },
)
// ==================== 启动监控 ====================
const startMonitoring = (id: string) => {
submissionId.value = id
submission.value = { id, result: 9 } as Submission // 9 = submitting
// 取消之前的断开计划
cancelScheduledDisconnect()
// 如果WebSocket未连接先连接
if (wsStatus.value !== "connected") {
console.log("[SubmissionMonitor] 启动WebSocket连接...")
connect()
}
// 等待WebSocket连接并订阅
let unwatch: (() => void) | null = null
unwatch = watch(
wsStatus,
(status) => {
if (status === "connected") {
console.log("[SubmissionMonitor] WebSocket已连接订阅提交:", id)
subscribe(id)
if (unwatch) {
unwatch() // 订阅成功后停止监听
}
}
},
{ immediate: true },
)
// 5秒后启动轮询保底防止WebSocket失败
startPollingFallback()
}
// ==================== 计算属性 ====================
const judging = computed(
() => submission.value?.result === SubmissionStatus.judging,
)
const pending = computed(
() => submission.value?.result === SubmissionStatus.pending,
)
const submitting = computed(
() => submission.value?.result === SubmissionStatus.submitting,
)
const isProcessing = computed(() => {
return judging.value || pending.value || submitting.value
})
// ==================== 清理 ====================
onUnmounted(() => {
pausePolling()
})
return {
// 状态
submissionId,
submission,
// 计算属性
judging,
pending,
submitting,
isProcessing,
// 方法
startMonitoring,
pausePolling,
}
}

View File

@@ -0,0 +1,284 @@
<script setup lang="ts">
import { getProblem } from "oj/api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { useScreenModeStore } from "shared/store/screenMode"
import { useMyFlowchartStore } from "shared/store/myFlowchart"
// 抽成具名 loader便于进页面时与接口并行预取编辑器 chunk
const loadProblemEditor = () => import("./components/ProblemEditor.vue")
const loadContestEditor = () => import("./components/ContestEditor.vue")
const ProblemEditor = defineAsyncComponent(loadProblemEditor)
const ContestEditor = defineAsyncComponent(loadContestEditor)
const EditorForTest = defineAsyncComponent(
() => import("./components/EditorForTest.vue"),
)
const ProblemContent = defineAsyncComponent(
() => import("./components/ProblemContent.vue"),
)
const ProblemInfo = defineAsyncComponent(
() => import("./components/ProblemInfo.vue"),
)
const ProblemSubmission = defineAsyncComponent(
() => import("./components/ProblemSubmission.vue"),
)
const ProblemReaction = defineAsyncComponent(
() => import("./components/ProblemReaction.vue"),
)
const ProblemFlowchart = defineAsyncComponent(
() => import("./components/ProblemFlowchart.vue"),
)
const MyFlowchartTab = defineAsyncComponent(
() => import("./components/MyFlowchartTab.vue"),
)
interface Props {
problemID: string
contestID?: string
problemSetId?: string
}
const { problemID, contestID = "", problemSetId = "" } = defineProps<Props>()
const errMsg = ref("无数据")
const route = useRoute()
const router = useRouter()
const problemStore = useProblemStore()
const screenModeStore = useScreenModeStore()
const myFlowchartStore = useMyFlowchartStore()
const { problem } = storeToRefs(problemStore)
const { shouldShowProblem } = storeToRefs(screenModeStore)
const { isMobile, isDesktop } = useBreakpoints()
const tabOptions = computed(() => {
const options: string[] = ["content"]
if (problem.value?.show_flowchart) {
options.push("flowchart")
}
if (isMobile.value) {
options.push("editor")
}
options.push("info")
if (!contestID) {
options.push("comment")
}
if (myFlowchartStore.showing) {
options.push("my-flowchart")
}
options.push("submission")
return options
})
const currentTab = ref("content")
const inProblem = computed(() => route.name === "problem")
watch(
[() => route.query.tab, () => tabOptions.value],
([rawTab]) => {
const tabs = tabOptions.value
const fallback = tabs[0] ?? "content"
currentTab.value = tabs.includes(rawTab as string)
? (rawTab as string)
: fallback
},
{ immediate: true },
)
watch(currentTab, (tab) => {
if (!tabOptions.value.includes(tab) || route.query.tab === tab) return
router.replace({
query: { ...route.query, tab },
})
})
watch(
() => myFlowchartStore.showing,
(showing) => {
if (showing) currentTab.value = "my-flowchart"
},
)
async function init() {
screenModeStore.resetScreenMode()
// 并行预取右侧编辑器 chunkCodeMirror ~370K+
// 避免等 getProblem 返回后才串行下载,编辑器才迟迟出现
;(inProblem.value ? loadProblemEditor : loadContestEditor)()
try {
const res = await getProblem(problemID, contestID)
problem.value = res.data
} catch (err: any) {
problem.value = null
if (err.data === "Contest has not started yet.") {
errMsg.value = "比赛还没有开始"
}
}
}
onMounted(init)
watch(() => problemID, init)
onBeforeUnmount(() => {
problem.value = null
errMsg.value = "无数据"
screenModeStore.resetScreenMode()
myFlowchartStore.hide()
})
watch(isMobile, (value) => {
if (value) screenModeStore.resetScreenMode()
})
// SQL 题不支持"自测"模式(外部代码运行器无法执行 SQL切到该屏时自动跳到下一模式
watch(
() => screenModeStore.isCodeOnlyMode,
(codeOnly) => {
if (codeOnly && problem.value?.languages.includes("SQL")) {
screenModeStore.switchScreenMode()
}
},
)
</script>
<template>
<template v-if="problem">
<n-split
v-if="isDesktop && screenModeStore.isBothMode"
direction="horizontal"
:default-size="0.43"
:min="0.2"
:max="0.8"
style="height: calc(100vh - 92px)"
>
<template #1>
<n-scrollbar style="height: 100%">
<n-tabs v-model:value="currentTab" type="segment">
<n-tab-pane name="content" tab="题目描述">
<ProblemContent />
</n-tab-pane>
<n-tab-pane
v-if="problem.show_flowchart && problem.mermaid_code"
name="flowchart"
tab="流程图表"
>
<ProblemFlowchart />
</n-tab-pane>
<n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId">
<ProblemInfo />
</n-tab-pane>
<n-tab-pane
v-if="!contestID"
name="comment"
tab="题目点评"
:disabled="!!problemSetId"
>
<ProblemReaction />
</n-tab-pane>
<n-tab-pane
v-if="myFlowchartStore.showing"
name="my-flowchart"
tab="我的流程图"
>
<MyFlowchartTab />
</n-tab-pane>
<n-tab-pane
name="submission"
tab="我的提交"
:disabled="!!problemSetId"
>
<ProblemSubmission />
</n-tab-pane>
</n-tabs>
</n-scrollbar>
</template>
<template #2>
<component :is="inProblem ? ProblemEditor : ContestEditor" />
</template>
</n-split>
<!-- Desktop: code only mode -->
<template v-else-if="isDesktop && screenModeStore.isCodeOnlyMode">
<EditorForTest />
</template>
<!-- Desktop: problem only mode -->
<template v-else-if="isDesktop && shouldShowProblem">
<n-scrollbar style="max-height: calc(100vh - 92px)">
<n-tabs v-model:value="currentTab" type="segment">
<n-tab-pane name="content" tab="题目描述">
<ProblemContent />
</n-tab-pane>
<n-tab-pane
v-if="problem.show_flowchart && problem.mermaid_code"
name="flowchart"
tab="流程图表"
>
<ProblemFlowchart />
</n-tab-pane>
<n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId">
<ProblemInfo />
</n-tab-pane>
<n-tab-pane
v-if="!contestID"
name="comment"
tab="题目点评"
:disabled="!!problemSetId"
>
<ProblemReaction />
</n-tab-pane>
<n-tab-pane
v-if="myFlowchartStore.showing"
name="my-flowchart"
tab="我的流程图"
>
<MyFlowchartTab />
</n-tab-pane>
<n-tab-pane
name="submission"
tab="我的提交"
:disabled="!!problemSetId"
>
<ProblemSubmission />
</n-tab-pane>
</n-tabs>
</n-scrollbar>
</template>
<!-- Mobile -->
<n-tabs v-else v-model:value="currentTab" type="segment">
<n-tab-pane name="content" tab="描述">
<ProblemContent />
</n-tab-pane>
<n-tab-pane v-if="problem.show_flowchart" name="flowchart" tab="流程">
<ProblemFlowchart />
</n-tab-pane>
<n-tab-pane name="editor" tab="代码">
<component :is="inProblem ? ProblemEditor : ContestEditor" />
</n-tab-pane>
<n-tab-pane name="info" tab="统计" :disabled="!!problemSetId">
<ProblemInfo />
</n-tab-pane>
<n-tab-pane
v-if="!contestID"
name="comment"
tab="点评"
:disabled="!!problemSetId"
>
<ProblemReaction />
</n-tab-pane>
<n-tab-pane
v-if="myFlowchartStore.showing"
name="my-flowchart"
tab="我的流程图"
>
<MyFlowchartTab />
</n-tab-pane>
<n-tab-pane name="submission" tab="提交" :disabled="!!problemSetId">
<ProblemSubmission />
</n-tab-pane>
</n-tabs>
</template>
<n-empty v-else :description="errMsg"></n-empty>
</template>

View File

@@ -0,0 +1,342 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { NFlex, NTag } from "naive-ui"
import { useRouteQuery } from "@vueuse/router"
import { getProblemList, getRandomProblemID } from "oj/api"
import { getTagColor } from "utils/functions"
import type { ProblemFiltered } from "utils/types"
import { getProblemTagList } from "shared/api"
import Hitokoto from "shared/components/Hitokoto.vue"
import Pagination from "shared/components/Pagination.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { usePagination } from "shared/composables/pagination"
import { useUserStore } from "shared/store/user"
import { renderTableTitle } from "utils/renders"
import ProblemStatus from "./components/ProblemStatus.vue"
import AuthorSelect from "shared/components/AuthorSelect.vue"
import ProblemListTitle from "./components/ProblemListTitle.vue"
interface Tag {
id: number
name: string
checked: boolean
}
interface ProblemQuery {
keyword: string
difficulty: string
tag: string
author: string
sort: string
}
const difficultyOptions = [
{ label: "全部", value: "" },
{ label: "简单", value: "Low" },
{ label: "中等", value: "Mid" },
{ label: "困难", value: "High" },
]
const sortOptions = [
{ label: "最新创建", value: "" },
{ label: "最早创建", value: "create_time" },
{ label: "最多提交", value: "-submission_number" },
{ label: "最少提交", value: "submission_number" },
{ label: "最多通过", value: "-accepted_number" },
{ label: "最少通过", value: "accepted_number" },
{ label: "画流程图", value: "flowchart" },
{ label: "语法检查", value: "ast" },
]
const router = useRouter()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const problems = ref<ProblemFiltered[]>([])
const total = ref(0)
const tags = ref<Tag[]>([])
const [showTag, toggleShowTag] = useToggle(isDesktop.value)
// 使用分页 composable
const { query, clearQuery } = usePagination<ProblemQuery>({
keyword: useRouteQuery("keyword", "").value,
difficulty: useRouteQuery("difficulty", "").value,
tag: useRouteQuery("tag", "").value,
author: useRouteQuery("author", "").value,
sort: useRouteQuery("sort", "").value,
})
async function listProblems() {
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit
const res = await getProblemList(offset, query.limit, {
keyword: query.keyword,
tag: query.tag,
difficulty: query.difficulty,
author: query.author,
sort: query.sort,
})
total.value = res.total
problems.value = res.results
}
async function listTags() {
const res = await getProblemTagList()
tags.value = res.data.map((r: Omit<Tag, "checked">) => ({
...r,
checked: query.tag === r.name,
}))
}
function chooseTag(tag: Tag) {
query.tag = tag.checked ? "" : tag.name
tags.value = tags.value.map((t) => {
if (t.id === tag.id) {
t.checked = !t.checked
} else {
t.checked = false
}
return t
})
}
async function getRandom() {
const res = await getRandomProblemID()
router.push("/problem/" + res.data)
}
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listProblems, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(
() => [
query.tag,
query.difficulty,
query.limit,
query.page,
query.author,
query.sort,
],
listProblems,
)
// 监听标签变化,更新标签选中状态
watch(
() => query.tag,
() => {
tags.value = tags.value.map((r: Omit<Tag, "checked">) => ({
...r,
checked: query.tag === r.name,
}))
},
)
watch(
() => userStore.isFinished && userStore.isAuthed,
(isAuthenticatedAndFinished) => {
if (isAuthenticatedAndFinished) {
listProblems()
}
},
)
onMounted(() => {
listProblems()
listTags()
})
const baseColumns: DataTableColumn<ProblemFiltered>[] = [
{
title: renderTableTitle("状态", "streamline-emojis:high-voltage"),
key: "status",
width: 80,
align: "center",
render: (row) => h(ProblemStatus, { status: row.status }),
},
{
title: renderTableTitle(
"编号",
"streamline-ultimate-color:board-game-dice-1",
),
key: "_id",
width: 100,
},
{
title: renderTableTitle(
"题目",
"streamline-ultimate-color:fruit-watermelon",
),
key: "title",
minWidth: 200,
render: (row) => h(ProblemListTitle, { problem: row }),
},
{
title: renderTableTitle("难度", "streamline-emojis:lady-beetle"),
key: "difficulty",
width: 100,
render: (row) =>
h(NTag, { type: getTagColor(row.difficulty) }, () => row.difficulty),
},
{
title: renderTableTitle("标签", "streamline-ultimate-color:attachment"),
key: "tags",
width: 260,
render: (row) =>
h(NFlex, () => row.tags.map((t) => h(NTag, { key: t }, () => t))),
},
{
title: renderTableTitle("出题者", "streamline-emojis:man-raising-hand-2"),
key: "author",
width: 130,
},
{
title: renderTableTitle("提交数", "streamline-ultimate-color:paper-write"),
key: "submission",
align: "center",
width: 100,
},
{
title: renderTableTitle("通过率", "streamline-emojis:victory-hand-2"),
key: "rate",
width: 100,
align: "center",
},
]
const columns = computed(() =>
userStore.isAuthed
? baseColumns
: baseColumns.filter((c: any) => c.key !== "status"),
)
function rowProps(row: ProblemFiltered) {
return {
style: "cursor: pointer",
onClick() {
router.push("/problem/" + row._id)
},
}
}
</script>
<template>
<n-flex vertical size="large">
<div class="problem-list-toolbar">
<n-space>
<n-form :show-feedback="false" inline label-placement="left">
<n-form-item label="难度">
<n-select
style="width: 80px"
v-model:value="query.difficulty"
:options="difficultyOptions"
/>
</n-form-item>
<n-form-item label="出题者">
<AuthorSelect v-model:value="query.author" />
</n-form-item>
</n-form>
<n-form :show-feedback="false" inline label-placement="left">
<n-form-item label="排序">
<n-select
style="width: 120px"
v-model:value="query.sort"
:options="sortOptions"
:dropdown-style="{ maxHeight: 'unset' }"
/>
</n-form-item>
<n-form-item>
<n-input
clearable
style="width: 160px"
v-model:value="query.keyword"
placeholder="题号或标题"
/>
</n-form-item>
</n-form>
<n-form :show-feedback="false" inline label-placement="left">
<n-form-item>
<n-button @click="clearQuery" quaternary>重置</n-button>
</n-form-item>
<!-- <n-form-item>
<n-button @click="getRandom" quaternary>随机</n-button>
</n-form-item> -->
<n-form-item>
<n-button
@click="toggleShowTag()"
quaternary
icon-placement="right"
>
<template #icon>
<Icon v-if="showTag" icon="ph:caret-down"></Icon>
<Icon v-else icon="ph:caret-up"></Icon>
</template>
标签
</n-button>
</n-form-item>
</n-form>
</n-space>
<Hitokoto v-if="isDesktop" class="problem-list-hitokoto" />
</div>
<n-collapse-transition :show="showTag">
<n-flex>
<n-tag
v-for="tag in tags"
:closable="tag.checked"
@close="chooseTag(tag)"
@click="chooseTag(tag)"
:key="tag.id"
:type="tag.checked ? 'success' : 'default'"
>
{{ tag.name }}
</n-tag>
</n-flex>
</n-collapse-transition>
<n-data-table
:bordered="false"
:data="problems"
:columns="columns"
:row-props="rowProps"
/>
</n-flex>
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</template>
<style scoped>
.problem-list-toolbar {
display: grid;
grid-template-columns: minmax(0, auto) minmax(250px, 1fr);
align-items: start;
gap: 12px 16px;
}
.problem-list-toolbar :deep(.n-space) {
min-width: 0;
}
.problem-list-hitokoto {
justify-self: end;
width: 100%;
max-width: 720px;
min-width: 0;
}
@media (max-width: 768px) {
.problem-list-toolbar {
grid-template-columns: minmax(0, 1fr);
}
.problem-list-toolbar :deep(.n-space) {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,53 @@
export interface PythonSyntaxError {
line: number
}
let skulptPromise: Promise<any> | null = null
/**
* 按需加载 Skulpt约 233KB gzip只在提交 Python3 代码时才下载。
* 结果缓存,同一页面只加载一次。
*/
function loadSkulpt(): Promise<any> {
if (!skulptPromise) {
// @ts-ignore - skulpt has no type definitions
skulptPromise = import("skulpt").then((m) => m.default ?? m)
}
return skulptPromise
}
/**
* 提前把 Skulpt 拉下来,避免点提交时才开始下载。
* 失败不影响功能,提交时会再试一次。
*/
export function prefetchPythonSyntaxChecker() {
loadSkulpt().catch(() => {
skulptPromise = null
})
}
/**
* 用 Skulpt 检测 Python 代码中的语法错误。
* 只编译不执行,不受 input() 等 IO 调用影响。
* 加载失败时返回 null放行提交交给后端判题兜底。
*/
export async function checkPythonSyntax(
code: string,
): Promise<PythonSyntaxError | null> {
let Sk: any
try {
Sk = await loadSkulpt()
} catch {
skulptPromise = null
return null
}
Sk.configure({ output: () => {} })
try {
Sk.compile(code, "prog.py", "exec")
return null
} catch (e: any) {
const line: number = e?.traceback?.[0]?.lineno ?? 1
return { line }
}
}

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { ProblemSet, UserBadge as UserBadgeType } from "utils/types"
import UserBadge from "shared/components/UserBadge.vue"
import { useUserStore } from "shared/store/user"
interface Props {
problemSet: ProblemSet
isJoined: boolean
isJoining: boolean
userBadges: UserBadgeType[]
}
interface Emits {
(e: "join"): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const userStore = useUserStore()
function getDifficultyTag(difficulty: string) {
const difficultyMap: Record<
string,
{ type: "success" | "warning" | "error" | "default"; text: string }
> = {
Easy: { type: "success", text: "简单" },
Medium: { type: "warning", text: "中等" },
Hard: { type: "error", text: "困难" },
}
return difficultyMap[difficulty] || { type: "default", text: "未知" }
}
function getProgressPercentage() {
if (!props.problemSet) return 0
return Math.round(
(props.problemSet.completed_count / props.problemSet.problems_count) * 100,
)
}
function handleJoin() {
emit("join")
}
</script>
<template>
<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="getDifficultyTag(problemSet.difficulty).type">
{{ getDifficultyTag(problemSet.difficulty).text }}
</n-tag>
<n-h2 style="margin: 0">{{ problemSet.title }}</n-h2>
<n-tooltip trigger="hover" v-if="problemSet.description">
<template #trigger>
<Icon width="20" icon="fluent-emoji:information" />
</template>
{{ problemSet.description }}
</n-tooltip>
</n-flex>
<n-flex align="center" v-if="userStore.isAuthed">
<!-- 用户徽章显示区域 - 只在已加入且有徽章时显示 -->
<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"
/>
</n-flex>
<!-- 完成进度 - 只在已加入时显示 -->
<n-flex align="center" v-if="isJoined">
<n-text strong>完成进度</n-text>
<n-text>
{{ problemSet.completed_count }} / {{ problemSet.problems_count }}
</n-text>
</n-flex>
<n-progress
v-if="isJoined"
:percentage="getProgressPercentage()"
:height="8"
:border-radius="4"
style="width: 200px"
/>
<n-button
v-if="!isJoined"
type="primary"
size="large"
:loading="isJoining"
@click="handleJoin"
>
加入题单
</n-button>
<n-tag v-else type="success" size="large">
<template #icon>
<Icon icon="ph:check-circle-fill" />
</template>
已加入
</n-tag>
</n-flex>
</n-flex>
</n-card>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { ProblemSetProblem } from "utils/types"
import { DIFFICULTY } from "utils/constants"
import { getTagColor } from "utils/functions"
import { useBreakpoints } from "shared/composables/breakpoints"
interface Props {
problems: ProblemSetProblem[]
isJoined: boolean
}
interface Emits {
(e: "problem-click", problemId: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { isDesktop } = useBreakpoints()
function handleProblemClick(problemId: string) {
emit("problem-click", problemId)
}
</script>
<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-card
hoverable
@click="handleProblemClick(problemSetProblem.problem._id)"
style="cursor: pointer"
>
<n-flex align="center">
<Icon
style="margin-right: 10px"
width="48"
icon="fluent-emoji:check-mark-button"
v-if="problemSetProblem.is_completed"
/>
<n-flex vertical style="flex: 1">
<n-flex align="center">
<n-h4 style="margin: 0">#{{ index + 1 }}</n-h4>
<n-h4 style="margin: 0">
{{ problemSetProblem.problem.title }}
</n-h4>
</n-flex>
<n-flex align="center" size="small">
<n-tag
:type="getTagColor(problemSetProblem.problem.difficulty)"
size="small"
>
{{ DIFFICULTY[problemSetProblem.problem.difficulty] }}
</n-tag>
<n-text type="info">分数{{ problemSetProblem.score }}</n-text>
<n-text v-if="!problemSetProblem.is_required">选做</n-text>
</n-flex>
</n-flex>
</n-flex>
</n-card>
</n-grid-item>
</n-grid>
<div class="tip">
<n-text depth="3">题目完成后会自动返回题单页面</n-text>
</div>
</div>
</template>
<style scoped>
.tip {
padding-top: 24px;
text-align: center;
}
</style>

View File

@@ -0,0 +1,268 @@
<script setup lang="ts">
import { h, computed, ref, onMounted, watch } from "vue"
import { watchDebounced } from "@vueuse/core"
import { parseTime } from "utils/functions"
import type { ProblemSetProgress } from "utils/types"
import { getProblemSetUserProgress } from "../../api"
import { NFlex, NTag } from "naive-ui"
import { usePagination } from "shared/composables/pagination"
import Pagination from "shared/components/Pagination.vue"
const route = useRoute()
const problemSetId = computed(() => Number(route.params.problemSetId))
const progress = ref<ProblemSetProgress[]>([])
const loading = ref(false)
const total = ref(0)
const statistics = ref<{
total: number
completed: number
avg_progress: number
} | null>(null)
const classFilter = ref<string>("")
const completionFilter = ref<"" | "completed" | "in_progress" | "not_started">(
"",
)
const allProblems = ref<Array<{ id: number; _id: string; title: string }>>([])
// 完成度筛选选项
const completionOptions = [
{ label: "全部", value: "" },
{ label: "未开始", value: "not_started" },
{ label: "进行中", value: "in_progress" },
{ label: "已完成", value: "completed" },
]
// 使用分页 composable
const { query } = usePagination({}, { defaultLimit: 50 })
// 加载用户进度数据
async function loadUserProgress() {
loading.value = true
const offset = (query.page - 1) * query.limit
const params: {
limit?: number
offset?: number
class_name?: string
completion_status?: "" | "completed" | "in_progress" | "not_started"
} = {
limit: query.limit,
offset,
}
if (classFilter.value.trim()) {
params.class_name = classFilter.value.trim()
}
if (completionFilter.value) {
params.completion_status = completionFilter.value
}
const res = await getProblemSetUserProgress(problemSetId.value, params)
progress.value = res.data.results
total.value = res.data.total
// 使用后端返回的统计数据(基于所有数据)
if (res.data.statistics) {
statistics.value = res.data.statistics
}
// 保存所有题目信息
if (res.data.problems) {
allProblems.value = res.data.problems
}
loading.value = false
}
// 监听分页参数变化
watch([() => query.page, () => query.limit], loadUserProgress)
// 监听班级过滤变化(防抖)
watchDebounced(
classFilter,
() => {
query.page = 1 // 重置到第一页
loadUserProgress()
},
{ debounce: 500 },
)
// 监听完成度筛选变化
watch(completionFilter, () => {
query.page = 1 // 重置到第一页
loadUserProgress()
})
// 使用后端返回的统计数据
const stats = computed(() => {
if (statistics.value) {
return {
total: statistics.value.total,
completed: statistics.value.completed,
avgProgress: Math.round(statistics.value.avg_progress),
}
}
// 如果后端还没有返回统计数据,使用默认值
return {
total: total.value,
completed: 0,
avgProgress: 0,
}
})
onMounted(loadUserProgress)
// 定义表格列
const progressColumns = [
{
title: "排名",
key: "rank",
width: 80,
render: (row: ProblemSetProgress, index: number) => {
// 计算全局排名:当前页偏移 + 当前行索引 + 1
const globalRank = (query.page - 1) * query.limit + index + 1
return globalRank
},
},
{
title: "用户",
key: "user.username",
width: 120,
render: (row: ProblemSetProgress) => row.user.username,
},
{
title: "加入时间",
key: "join_time",
width: 180,
render: (row: ProblemSetProgress) =>
parseTime(row.join_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "已完成数量",
key: "completed_problems_count",
width: 100,
},
{
title: "已/未完成题目",
key: "completed_problems",
width: 300,
render: (row: ProblemSetProgress) => {
if (row.progress_percentage === 100) {
return "全部题目已完成"
}
if (row.progress_percentage > 50 && row.progress_percentage < 100) {
const completedProblemIds = new Set(
row.completed_problems.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) =>
h(
NTag,
{ type: "warning", size: "small", style: "margin: 2px" },
() => `${problem._id}: ${problem.title}`,
),
),
),
])
}
return h("div", { style: "max-height: 120px; overflow-y: auto" }, [
h(NFlex, {}, () =>
row.completed_problems.map((problem: any) =>
h(
NTag,
{
type: "success",
size: "small",
style: "margin: 2px",
},
() => `${problem._id}: ${problem.title}`,
),
),
),
])
},
},
{
title: "进度",
key: "progress_percentage",
width: 120,
render: (row: ProblemSetProgress) => {
return `${row.progress_percentage.toFixed(0)}%`
},
},
{
title: "状态",
key: "is_completed",
width: 100,
render: (row: ProblemSetProgress) => {
if (row.is_completed) {
return h(NTag, { type: "success" }, () => "已完成")
} else {
return h(NTag, { type: "warning" }, () => "进行中")
}
},
},
]
</script>
<template>
<div>
<!-- 过滤条件 -->
<n-form label-placement="left" inline>
<n-form-item label="班级">
<n-input
v-model:value="classFilter"
placeholder="输入班级名称"
style="width: 200px"
clearable
/>
</n-form-item>
<n-form-item label="完成度:">
<n-select
v-model:value="completionFilter"
:options="completionOptions"
placeholder="完成度"
style="width: 160px"
clearable
/>
</n-form-item>
</n-form>
<!-- 统计信息卡片 -->
<n-grid :cols="3" :x-gap="16" style="margin-bottom: 16px">
<n-grid-item>
<n-card size="small">
<n-statistic label="总参与人数" :value="stats.total" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<n-statistic label="已完成人数" :value="stats.completed" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card size="small">
<n-statistic
label="平均进度"
:value="stats.avgProgress.toFixed(0) + '%'"
/>
</n-card>
</n-grid-item>
</n-grid>
<n-data-table
:loading="loading"
:columns="progressColumns"
:data="progress"
:pagination="false"
:bordered="false"
:single-line="false"
/>
<Pagination
:total="total"
:limit="query.limit"
:page="query.page"
@update:limit="(limit: number) => (query.limit = limit)"
@update:page="(page: number) => (query.page = page)"
/>
</div>
</template>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
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"
import UserProgressView from "./components/UserProgressView.vue"
import { useUserStore } from "shared/store/user"
const route = useRoute()
const router = useRouter()
const message = useMessage()
const { celebrate } = useFireworks()
const userStore = useUserStore()
const problemSetId = computed(() => Number(route.params.problemSetId))
const problemSet = ref<ProblemSet | null>(null)
const problems = ref<ProblemSetProblem[]>([])
const isJoined = ref(false)
const isJoining = ref(false)
const userBadges = ref<UserBadgeType[]>([])
const activeTab = ref("problems")
async function loadProblemSetDetail() {
const res = await getProblemSetDetail(problemSetId.value)
problemSet.value = res.data
isJoined.value = res.data.user_progress?.is_joined || false
}
async function loadProblems() {
const res = await getProblemSetProblems(problemSetId.value)
problems.value = res.data
}
async function loadUserBadges() {
if (!isJoined.value) return
const res = await getUserBadges()
userBadges.value = res.data.filter(
(badge: UserBadgeType) => badge.badge.problemset === problemSetId.value,
)
}
async function init() {
await Promise.all([loadProblemSetDetail(), loadProblems()])
if (isJoined.value) {
if (problemSet.value?.user_progress?.is_completed) {
celebrate()
}
loadUserBadges()
}
}
async function handleProblemClick(problemId: string) {
if (!userStore.isAuthed) {
message.warning("请先登录!")
return
}
if (!isJoined.value) {
message.warning("请先点击【加入题单】按钮!")
return
}
router.push({
name: "problemset problem",
params: {
problemSetId: problemSetId.value,
problemID: problemId,
},
})
}
async function handleJoinProblemSet() {
if (isJoining.value) return
isJoining.value = true
try {
await joinProblemSet(problemSetId.value)
isJoined.value = true
message.success("成功加入题单!")
// 加入题单后加载用户徽章
await loadUserBadges()
} catch (err: any) {
message.error("加入题单失败:" + (err.data || "未知错误"))
} finally {
isJoining.value = false
}
}
const showTabs = computed(
() =>
userStore.isSuperAdmin ||
(isJoined.value && problemSet.value?.user_progress?.is_completed),
)
onMounted(init)
</script>
<template>
<div v-if="problemSet">
<ProblemSetHeader
:problem-set="problemSet"
:is-joined="isJoined"
:is-joining="isJoining"
:user-badges="userBadges"
@join="handleJoinProblemSet"
/>
<n-tabs v-if="showTabs" v-model:value="activeTab" animated>
<n-tab-pane name="problems" tab="题目列表">
<ProblemSetProblemsList
:problems="problems"
:is-joined="isJoined"
@problem-click="handleProblemClick"
/>
</n-tab-pane>
<n-tab-pane name="progress" tab="用户进度">
<UserProgressView />
</n-tab-pane>
</n-tabs>
<ProblemSetProblemsList
v-else
:problems="problems"
:is-joined="isJoined"
@problem-click="handleProblemClick"
/>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,274 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useRouteQuery } from "@vueuse/router"
import { getProblemSetList } from "../api"
import { parseTime } from "utils/functions"
import type { ProblemSetList } from "utils/types"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { useBreakpoints } from "shared/composables/breakpoints"
const router = useRouter()
const { isDesktop } = useBreakpoints()
const total = ref(0)
const problemSets = ref<ProblemSetList[]>([])
interface ProblemSetQuery {
keyword: string
difficulty: string
status: string
}
// 使用分页 composable
const { query, clearQuery } = usePagination<ProblemSetQuery>(
{
keyword: useRouteQuery("keyword", "").value,
difficulty: useRouteQuery("difficulty", "").value,
status: useRouteQuery("status", "").value,
},
{
defaultLimit: 30,
},
)
const difficultyOptions = [
{ label: "全部", value: "" },
{ label: "简单", value: "Easy" },
{ label: "中等", value: "Medium" },
{ label: "困难", value: "Hard" },
]
const statusOptions = [
{ label: "全部", value: "" },
{ label: "活跃", value: "active" },
{ label: "已归档", value: "archived" },
]
async function listProblemSets() {
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit
const res = await getProblemSetList(
offset,
query.limit,
query.keyword,
query.difficulty,
query.status,
)
total.value = res.data.total
problemSets.value = res.data.results
}
function getDifficultyTag(difficulty: string) {
const difficultyMap: Record<
string,
{ type: "success" | "warning" | "error" | "default"; text: string }
> = {
Easy: { type: "success", text: "简单" },
Medium: { type: "warning", text: "中等" },
Hard: { type: "error", text: "困难" },
}
return difficultyMap[difficulty] || { type: "default", text: "未知" }
}
function goToProblemSet(problemSetId: number) {
router.push(`/problemset/${problemSetId}`)
}
function getConditionText(
conditionType: string,
conditionValue: number,
): string {
const conditionMap: Record<string, string> = {
all_problems: "完成所有题目",
problem_count: `完成 ${conditionValue} 道题目`,
score: `达到 ${conditionValue}`,
}
return conditionMap[conditionType] || "未知条件"
}
function getProgressColor(percentage: number) {
if (percentage >= 80) return "#18a058" // 绿色
if (percentage >= 50) return "#f0a020" // 橙色
return "#d03050" // 红色
}
onMounted(listProblemSets)
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listProblemSets, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(
() => [query.page, query.limit, query.difficulty, query.status],
listProblemSets,
)
</script>
<template>
<n-flex vertical size="large">
<n-space>
<n-space align="center">
<n-text>难度</n-text>
<n-select
v-model:value="query.difficulty"
:options="difficultyOptions"
placeholder="选择难度"
style="width: 120px"
clearable
/>
</n-space>
<n-space align="center">
<n-text>状态</n-text>
<n-select
v-model:value="query.status"
:options="statusOptions"
placeholder="选择状态"
style="width: 120px"
clearable
/>
</n-space>
<n-input
v-model:value="query.keyword"
placeholder="搜索题单..."
clearable
@clear="clearQuery"
style="width: 200px"
/>
</n-space>
<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"
>
<template #header>
<n-flex justify="space-between" align="center">
<n-text strong>{{ problemSet.title }}</n-text>
<n-tag :type="getDifficultyTag(problemSet.difficulty).type">
{{ getDifficultyTag(problemSet.difficulty).text }}
</n-tag>
</n-flex>
</template>
<n-flex vertical size="large">
<n-flex justify="space-between" align="center">
<n-flex>
<Icon width="20" icon="streamline-emojis:blossom" />
<n-text>{{ problemSet.problems_count }} 道题目</n-text>
</n-flex>
<n-flex align="center" style="height: 28px">
<!-- 用户进度显示 -->
<n-progress
v-if="
problemSet.user_progress?.is_joined &&
!problemSet.user_progress?.is_completed
"
type="line"
:percentage="
Math.round(problemSet.user_progress.progress_percentage)
"
:height="4"
:border-radius="2"
style="width: 100px"
:color="
getProgressColor(
problemSet.user_progress.progress_percentage,
)
"
/>
<n-tag type="warning" v-if="problemSet.status === 'archived'">
已归档
</n-tag>
<n-tag
v-if="
problemSet.user_progress?.is_joined &&
!problemSet.user_progress?.is_completed
"
type="warning"
>
已加入
</n-tag>
<n-tag
v-if="problemSet.user_progress?.is_completed"
type="error"
>
已完成
</n-tag>
</n-flex>
</n-flex>
<!-- 奖章显示 -->
<n-flex align="center" justify="space-between">
<n-text depth="3">
创建于
{{ parseTime(problemSet.create_time, "YYYY-MM-DD") }}
</n-text>
<n-flex>
<n-tooltip
v-for="badge in problemSet.badges"
:key="badge.id"
trigger="hover"
>
<template #trigger>
<n-image
:src="badge.icon"
:alt="badge.name"
width="24"
height="24"
object-fit="cover"
:class="{ 'earned-badge': badge.is_earned }"
/>
</template>
<n-flex vertical size="small">
<span style="font-weight: bold">
徽章: {{ badge.name }}
</span>
<span>
获取条件:
{{
getConditionText(
badge.condition_type,
badge.condition_value,
)
}}
</span>
<n-text type="primary" v-if="badge.is_earned">
✓ 已获得
</n-text>
</n-flex>
</n-tooltip>
</n-flex>
</n-flex>
</n-flex>
</n-card>
</n-grid-item>
</n-grid>
<Pagination
v-if="problemSets.length > 0"
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</n-flex>
<n-empty v-if="problemSets.length === 0"></n-empty>
</template>
<style scoped>
.earned-badge {
border: 2px solid #ffd700;
border-radius: 50%;
box-shadow: 0 0 8px rgba(255, 215, 0, 0.4);
}
</style>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
import { Bar } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
Colors,
} from "chart.js"
import { ChartType } from "utils/constants"
import type { Rank } from "utils/types"
// 仅注册柱状图所需的 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
Colors,
)
const props = defineProps<{ rankData: Rank[]; type: ChartType }>()
const data = computed(() => {
const labels = props.rankData.map((rank) => rank.user.username)
const datasets: any[] = [
{
label: props.type === ChartType.Rank ? "已解决" : "做题数",
data: props.rankData.map((rank) => rank.accepted_number),
backgroundColor: [
"rgba(255, 99, 132, 0.2)",
"rgba(255, 159, 64, 0.2)",
"rgba(55, 66, 250, 0.2)",
"rgba(75, 192, 192, 0.2)",
"rgba(54, 162, 235, 0.2)",
"rgba(153, 102, 255, 0.2)",
"rgba(48, 51, 107, 0.2)",
"rgba(249, 202, 36, 0.2)",
"rgba(106, 176, 76, 0.2)",
"rgba(119, 139, 235, 0.2)",
],
borderColor: [
"rgba(255, 99, 132, 0.6)",
"rgba(255, 159, 64, 0.6)",
"rgba(55, 66, 250, 0.6)",
"rgba(75, 192, 192, 0.6)",
"rgba(54, 162, 235, 0.6)",
"rgba(153, 102, 255, 0.6)",
"rgba(48, 51, 107, 0.6)",
"rgba(249, 202, 36, 0.6)",
"rgba(106, 176, 76, 0.6)",
"rgba(119, 139, 235, 0.6)",
],
hoverBackgroundColor: [
"rgba(255, 99, 132, 0.8)",
"rgba(255, 159, 64, 0.8)",
"rgba(55, 66, 250, 0.8)",
"rgba(75, 192, 192, 0.8)",
"rgba(54, 162, 235, 0.8)",
"rgba(153, 102, 255, 0.8)",
"rgba(48, 51, 107, 0.8)",
"rgba(249, 202, 36, 0.8)",
"rgba(106, 176, 76, 0.8)",
"rgba(119, 139, 235, 0.8)",
],
hoverBorderColor: [
"rgba(255, 99, 132, 1)",
"rgba(255, 159, 64, 1)",
"rgba(55, 66, 250, 1)",
"rgba(75, 192, 192, 1)",
"rgba(54, 162, 235, 1)",
"rgba(153, 102, 255, 1)",
"rgba(48, 51, 107, 1)",
"rgba(249, 202, 36, 1)",
"rgba(106, 176, 76, 1)",
"rgba(119, 139, 235, 1)",
],
borderWidth: 2,
},
]
if (props.type === ChartType.Rank) {
datasets.push({
label: "总提交数",
data: props.rankData.map((rank) => rank.submission_number),
hidden: true,
})
}
return {
labels,
datasets,
}
})
const options = {
maintainAspectRatio: false,
}
</script>
<template>
<div class="chart">
<Bar :data="data" :options="options" />
</div>
</template>
<style scoped>
.chart {
height: 500px;
margin: 20px 0;
}
</style>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
interface Props {
page: number
limit: number
index: number
}
const props = defineProps<Props>()
const index = computed(() => props.index + (props.page - 1) * props.limit + 1)
const tooltip = computed(() => {
if (index.value === 1) return "🏅 金牌"
if (index.value === 2) return "🥈 银牌"
if (index.value === 3) return "🥉 铜牌"
return ""
})
</script>
<template>
<span v-if="index > 3">{{ index }}</span>
<n-tooltip v-else>
<template #trigger>
<n-icon :size="24">
<Icon v-if="index === 1" icon="fluent-emoji:1st-place-medal"></Icon>
<Icon v-if="index === 2" icon="fluent-emoji:2nd-place-medal"></Icon>
<Icon v-if="index === 3" icon="fluent-emoji:3rd-place-medal"></Icon>
</n-icon>
</template>
{{ tooltip }}
</n-tooltip>
</template>

View File

@@ -0,0 +1,880 @@
<script setup lang="ts">
import { formatISO, sub, type Duration } from "date-fns"
import { NButton, NFlex } from "naive-ui"
import {
getActivityRank,
getClassRank,
getRank,
getUserClassRank,
getClassPK,
} from "oj/api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { getACRate, getCSRFToken } from "utils/functions"
import type { Rank } from "utils/types"
import Pagination from "shared/components/Pagination.vue"
import { ChartType } from "utils/constants"
import { renderTableTitle } from "utils/renders"
import Chart from "./components/Chart.vue"
import Index from "./components/Index.vue"
import { useUserStore } from "shared/store/user"
import { Icon } from "@iconify/vue"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { consumeJSONEventStream } from "utils/stream"
const gradeOptions = [
{ label: "24年级", value: 24 },
{ label: "23年级", value: 23 },
{ label: "22年级", value: 22 },
{ label: "21年级", value: 21 },
{ label: "20年级", value: 20 },
]
const router = useRouter()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const data = ref<Rank[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
})
const rankChart = ref<Rank[]>([])
const activityChart = ref<Rank[]>([])
const duration = ref("months:1")
const classData = ref<ClassRank[]>([])
const classQuery = reactive({
grade: gradeOptions[0].value,
})
const myClassData = ref<UserRank[]>([])
const myRank = ref(-1)
const myClassName = ref("")
const myClassScope = ref<"window" | "all">("window")
const myClassTotal = ref(0)
const myClassQuery = reactive({
page: 1,
limit: 10,
})
const showClassDetailModal = ref(false)
const classDetailData = ref<ClassComparison | null>(null)
const classDetailLoading = ref(false)
const classDetailAiLoading = ref(false)
const classDetailAiContent = ref("")
const showClassDetailAiModal = ref(false)
let classDetailAiController: AbortController | null = null
async function loadClassDetail(className: string) {
showClassDetailModal.value = true
classDetailLoading.value = true
classDetailData.value = null
try {
const res = await getClassPK([className])
classDetailData.value = res.data.comparisons[0] ?? null
} catch {
// ignore
} finally {
classDetailLoading.value = false
}
}
async function analyzeSingleClassWithAI() {
if (!classDetailData.value) return
if (classDetailAiController) classDetailAiController.abort()
const controller = new AbortController()
classDetailAiController = controller
showClassDetailModal.value = false
showClassDetailAiModal.value = true
classDetailAiContent.value = ""
classDetailAiLoading.value = true
const headers: Record<string, string> = { "Content-Type": "application/json" }
const csrfToken = getCSRFToken()
if (csrfToken) headers["X-CSRFToken"] = csrfToken
try {
const response = await fetch("/api/ai/class_single", {
method: "POST",
headers,
body: JSON.stringify({ comparison: classDetailData.value }),
signal: controller.signal,
})
if (!response.ok) throw new Error("AI 分析生成失败")
let hasStarted = false
await consumeJSONEventStream(response, {
signal: controller.signal,
onEvent(event) {
if (event === "end" && !hasStarted) classDetailAiLoading.value = false
},
onMessage(payload) {
const parsed = payload as {
type?: string
content?: string
message?: string
}
if (parsed.type === "delta" && parsed.content) {
if (!hasStarted) {
hasStarted = true
classDetailAiLoading.value = false
}
classDetailAiContent.value += parsed.content
} else if (parsed.type === "error") {
throw new Error(parsed.message || "AI 服务异常")
} else if (parsed.type === "done" && !hasStarted) {
classDetailAiLoading.value = false
}
},
})
} catch (error: any) {
if (controller.signal.aborted) return
message.error(error?.message || "AI 分析失败,请稍后再试")
classDetailAiLoading.value = false
} finally {
if (classDetailAiController === controller) classDetailAiController = null
}
}
interface ClassRank {
rank: number
class_name: string
user_count: number
total_ac: number
total_submission: number
avg_ac: number
ac_rate: number
}
interface ClassComparison {
class_name: string
user_count: number
total_ac: number
total_submission: number
avg_ac: number
median_ac: number
q1_ac: number
q3_ac: number
iqr: number
std_dev: number
top_10_avg: number
middle_80_avg: number
bottom_10_avg: number
excellent_rate: number
pass_rate: number
active_rate: number
ac_rate: number
composite_score: number
}
interface UserRank {
rank: number
username: string
accepted_number: number
submission_number: number
}
async function init() {
const offset = (query.page - 1) * query.limit
const res = await getRank(offset, query.limit, 100)
data.value = res.data.results
total.value = res.data.total
return res.data.results
}
const columns: DataTableColumn<Rank>[] = [
{
title: renderTableTitle("排名", "streamline-emojis:flexed-biceps-1"),
key: "index",
width: 100,
align: "center",
render: (_, index) =>
h(Index, { index, page: query.page, limit: query.limit }),
},
{
title: renderTableTitle(
"用户",
"streamline-emojis:smiling-face-with-sunglasses",
),
key: "username",
width: 240,
render: (row) =>
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.user.username),
},
() => row.user.username,
),
h(
NButton,
{
text: true,
size: "tiny",
title: "查看成就",
onClick: () =>
router.push("/achievement?name=" + row.user.username),
},
() => "🏆",
),
]),
},
{
title: renderTableTitle(
"个性签名",
"streamline-emojis:no-one-under-eighteen",
),
key: "mood",
minWidth: 200,
},
{
title: renderTableTitle("已解决", "streamline-emojis:raised-fist-1"),
key: "accepted_number",
width: 120,
align: "center",
},
{
title: renderTableTitle(
"提交数",
"streamline-ultimate-color:space-rocket-earth",
),
key: "submission_number",
width: 120,
align: "center",
},
{
title: renderTableTitle("正确率", "streamline-ultimate-color:gift-box-1"),
key: "rate",
width: 120,
align: "center",
render: (row) => getACRate(row.accepted_number, row.submission_number),
},
]
watch(() => query.page, init)
watch(
() => query.limit,
() => {
query.page = 1
init()
},
)
watch(duration, listActivity)
async function listActivity() {
const current = Date.now()
const start = formatISO(sub(current, subOptions.value))
const res = await getActivityRank(start)
activityChart.value = res.data.map(
(d: { username: string; count: number }) => ({
user: {
username: d.username,
},
accepted_number: d.count,
submission_number: 0,
}),
)
}
async function listRank() {
const res = await getRank(0, 10, 10)
rankChart.value = res.data.results
}
const options: SelectOption[] = [
{ label: "一周内", value: "weeks:1" },
{ label: "一个月内", value: "months:1" },
{ label: "两个月内", value: "months:2" },
{ label: "半年内", value: "months:6" },
{ label: "一年内", value: "years:1" },
]
const subOptions = computed<Duration>(() => {
let dur = options.find((it) => it.value === duration.value) ?? options[1]
const x = dur.value!.toString().split(":")
const unit = x[0]
const n = x[1]
return { [unit]: parseInt(n) }
})
onMounted(() => {
init()
listRank()
listActivity()
listClassRank()
listMyClassRank()
})
const classColumns: DataTableColumn<ClassRank>[] = [
{
title: "排名",
key: "rank",
width: 60,
titleAlign: "center",
align: "center",
},
{
title: "班级",
key: "class_name",
render: (row) =>
`${row.class_name.slice(0, 2)}计算机${row.class_name.slice(2)}`,
minWidth: 120,
titleAlign: "center",
align: "center",
},
{
title: "人数",
key: "user_count",
width: 80,
titleAlign: "center",
align: "center",
},
{
title: "总AC数",
key: "total_ac",
width: 90,
titleAlign: "center",
align: "center",
},
{
title: "提交数",
key: "total_submission",
width: 90,
titleAlign: "center",
align: "center",
},
{
title: "平均AC数",
key: "avg_ac",
width: 100,
titleAlign: "center",
align: "center",
},
{
title: "正确率",
key: "ac_rate",
width: 90,
titleAlign: "center",
align: "center",
render: (row) => `${row.ac_rate}%`,
},
{
title: "详情",
key: "action",
width: 70,
titleAlign: "center",
align: "center",
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => loadClassDetail(row.class_name),
},
() => "查看",
),
},
]
const myClassColumns: DataTableColumn<UserRank>[] = [
{
title: "排名",
key: "rank",
width: 100,
align: "center",
},
{
title: "用户名",
key: "username",
width: 240,
render: (row) =>
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.username),
},
() =>
row.rank === myRank.value
? h(
NFlex,
{ align: "flex-end" },
{
default: () => [
h("span", {}, row.username),
h(Icon, {
width: 20,
icon: "fluent-emoji:person-raising-hand",
}),
],
},
)
: row.username,
),
h(
NButton,
{
text: true,
size: "tiny",
title: "查看成就",
onClick: () => router.push("/achievement?name=" + row.username),
},
() => "🏆",
),
]),
},
{
title: "已解决",
key: "accepted_number",
width: 120,
align: "center",
},
{
title: "提交数",
key: "submission_number",
width: 120,
align: "center",
},
]
async function listClassRank() {
if (!userStore.user) {
await userStore.getMyProfile()
}
const className = userStore.user?.class_name
if (className) {
classQuery.grade = parseInt(className.slice(0, 2))
}
const res = await getClassRank(classQuery.grade)
classData.value = res.data
}
async function listMyClassRank() {
try {
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.data.my_rank
myClassName.value = res.data.class_name
myClassData.value = res.data.ranks
myClassTotal.value = res.data.total ?? res.data.ranks.length
if (myClassScope.value === "window") {
myClassQuery.page = 1
}
} catch (err: any) {
console.error(err)
}
}
watch(
() => classQuery.grade,
() => {
listClassRank()
},
)
watch(myClassScope, listMyClassRank)
watch(
() => myClassQuery.page,
() => {
if (myClassScope.value === "all") {
listMyClassRank()
}
},
)
watch(
() => myClassQuery.limit,
() => {
myClassQuery.page = 1
if (myClassScope.value === "all") {
listMyClassRank()
}
},
)
</script>
<template>
<n-flex vertical size="large">
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
<n-gi :span="1">
<n-card>
<template #header>
<div style="height: 34px">全服 Top10</div>
</template>
<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>
<n-gi :span="1">
<n-card>
<template #header>日活 Top10</template>
<template #header-extra>
<n-select
style="width: 120px"
:options="options"
v-model:value="duration"
/>
</template>
<Chart
v-if="activityChart.length"
:type="ChartType.Activity"
:rank-data="activityChart"
/>
<n-empty v-else style="padding: 20px 0"></n-empty>
</n-card>
</n-gi>
</n-grid>
<n-card>
<template #header>全服 Top100</template>
<n-data-table :data="data" :columns="columns" />
<template #footer>
<Pagination
:total="total"
v-model:page="query.page"
v-model:limit="query.limit"
/>
</template>
</n-card>
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
<n-gi :span="1">
<n-card>
<template #header>
<n-flex align="center">
<span>班级排名</span>
<n-button
type="primary"
secondary
@click="router.push('/class')"
v-if="userStore.isAdminRole"
>
班级PK
</n-button>
</n-flex>
</template>
<template #header-extra>
<n-select
v-model:value="classQuery.grade"
placeholder="选择年级"
clearable
style="width: 180px"
:options="gradeOptions"
/>
</template>
<n-data-table :data="classData" :columns="classColumns" />
</n-card>
</n-gi>
<n-gi :span="1">
<n-card>
<template #header>我在班级的排名</template>
<template #header-extra>
<n-select
style="width: 180px"
:options="[
{ label: '我的位置', value: 'window' },
{ label: '全班排名', value: 'all' },
]"
v-model:value="myClassScope"
/>
</template>
<n-data-table :data="myClassData" :columns="myClassColumns" />
<template #footer v-if="myClassScope === 'all'">
<Pagination
:total="myClassTotal"
v-model:page="myClassQuery.page"
v-model:limit="myClassQuery.limit"
/>
</template>
</n-card>
</n-gi>
</n-grid>
</n-flex>
<n-modal
v-model:show="showClassDetailModal"
preset="card"
:title="
classDetailData
? `${classDetailData.class_name.slice(0, 2)}计算机${classDetailData.class_name.slice(2)}班`
: '班级详情'
"
:style="{ width: '700px', maxWidth: '95vw' }"
>
<n-spin :show="classDetailLoading" style="min-height: 200px">
<n-flex v-if="classDetailData" vertical :size="12">
<n-grid :cols="5" :x-gap="8" responsive="screen">
<n-gi>
<n-statistic
label="总AC数"
:value="classDetailData.total_ac"
size="large"
class="stat-total-ac"
>
<template #suffix>
<Icon icon="streamline-emojis:raised-fist-1" width="20" />
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic
label="平均AC数"
:value="classDetailData.avg_ac.toFixed(2)"
size="large"
class="stat-avg-ac"
>
<template #suffix>
<Icon
icon="streamline-ultimate-color:analytics-pie-2"
width="20"
/>
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic
label="中位数AC数"
:value="classDetailData.median_ac.toFixed(2)"
size="large"
class="stat-median-ac"
>
<template #suffix>
<Icon
icon="streamline-ultimate-color:cursor-target-1"
width="20"
/>
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic
label="总提交数"
:value="classDetailData.total_submission"
size="large"
class="stat-total-submission"
>
<template #suffix>
<Icon
icon="streamline-ultimate-color:common-file-text"
width="20"
/>
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic
label="AC率"
:value="classDetailData.ac_rate.toFixed(1) + '%'"
size="large"
class="stat-ac-rate"
>
<template #suffix>
<Icon icon="fluent-emoji:check-mark-button" width="20" />
</template>
</n-statistic>
</n-gi>
</n-grid>
<n-divider style="margin: 12px 0" />
<n-descriptions
bordered
:column="2"
size="small"
label-placement="left"
>
<n-descriptions-item label="第一四分位数(Q1)">
<span style="color: #9254de; font-weight: 500">{{
classDetailData.q1_ac.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="第三四分位数(Q3)">
<span style="color: #f759ab; font-weight: 500">{{
classDetailData.q3_ac.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="四分位距(IQR)">
<span style="color: #13c2c2; font-weight: 500">{{
classDetailData.iqr.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="标准差">
<span style="color: #fa8c16; font-weight: 500">{{
classDetailData.std_dev.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="前10%均值">
<span style="color: #cf1322; font-weight: 600">{{
classDetailData.top_10_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="中间80%均值">
<span style="color: #389e0d; font-weight: 600">{{
classDetailData.middle_80_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="后10%均值">
<span style="color: #096dd9; font-weight: 500">{{
classDetailData.bottom_10_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="人数">
<span style="color: #1890ff; font-weight: 600">{{
classDetailData.user_count
}}</span>
</n-descriptions-item>
</n-descriptions>
<n-card size="small" title="比率统计" embedded style="margin-top: 12px">
<n-space vertical :size="10">
<n-progress
type="line"
:percentage="classDetailData.excellent_rate"
:show-indicator="true"
:border-radius="4"
>
<template #default
>优秀率:
{{ classDetailData.excellent_rate.toFixed(1) }}%</template
>
</n-progress>
<n-progress
type="line"
:percentage="classDetailData.pass_rate"
:show-indicator="true"
:border-radius="4"
status="success"
>
<template #default
>及格率: {{ classDetailData.pass_rate.toFixed(1) }}%</template
>
</n-progress>
<n-progress
type="line"
:percentage="classDetailData.active_rate"
:show-indicator="true"
:border-radius="4"
status="info"
>
<template #default
>参与度: {{ classDetailData.active_rate.toFixed(1) }}%</template
>
</n-progress>
</n-space>
</n-card>
<n-flex
justify="center"
align="center"
:size="12"
style="margin-top: 12px"
>
<n-tag type="success" size="large">
综合分: {{ classDetailData.composite_score.toFixed(1) }}
</n-tag>
<n-button
type="info"
size="small"
:loading="classDetailAiLoading"
@click="analyzeSingleClassWithAI"
>
<template #icon>
<Icon icon="ph:sparkle" />
</template>
AI分析
</n-button>
</n-flex>
</n-flex>
<n-empty
v-else-if="!classDetailLoading"
description="暂无数据"
style="padding: 40px 0"
/>
</n-spin>
</n-modal>
<n-modal
v-model:show="showClassDetailAiModal"
preset="card"
title="AI 分析报告"
:style="{ width: '800px', maxWidth: '95vw' }"
>
<n-spin :show="classDetailAiLoading" :delay="50">
<div style="min-height: 200px">
<MdPreview
v-if="classDetailAiContent"
:model-value="classDetailAiContent"
/>
<n-flex
v-else-if="!classDetailAiLoading"
align="center"
justify="center"
style="min-height: 200px"
>
<n-empty description="暂无分析内容" />
</n-flex>
</div>
</n-spin>
</n-modal>
</template>
<style scoped>
.stat-total-ac :deep(.n-statistic-value),
.stat-total-ac :deep(.n-statistic-value__content),
.stat-total-ac :deep(.n-number-animation),
.stat-total-ac :deep(.n-statistic-value > *),
.stat-total-ac :deep(.n-statistic-value span) {
color: #ff4d4f !important;
font-weight: 600;
}
.stat-avg-ac :deep(.n-statistic-value),
.stat-avg-ac :deep(.n-statistic-value__content),
.stat-avg-ac :deep(.n-number-animation),
.stat-avg-ac :deep(.n-statistic-value > *),
.stat-avg-ac :deep(.n-statistic-value span) {
color: #52c41a !important;
font-weight: 600;
}
.stat-median-ac :deep(.n-statistic-value),
.stat-median-ac :deep(.n-statistic-value__content),
.stat-median-ac :deep(.n-number-animation),
.stat-median-ac :deep(.n-statistic-value > *),
.stat-median-ac :deep(.n-statistic-value span) {
color: #fa8c16 !important;
font-weight: 600;
}
.stat-total-submission :deep(.n-statistic-value),
.stat-total-submission :deep(.n-statistic-value__content),
.stat-total-submission :deep(.n-number-animation),
.stat-total-submission :deep(.n-statistic-value > *),
.stat-total-submission :deep(.n-statistic-value span) {
color: #805ad5 !important;
font-weight: 600;
}
.stat-ac-rate :deep(.n-statistic-value),
.stat-ac-rate :deep(.n-statistic-value__content),
.stat-ac-rate :deep(.n-number-animation),
.stat-ac-rate :deep(.n-statistic-value > *),
.stat-ac-rate :deep(.n-statistic-value span) {
color: #00b894 !important;
font-weight: 600;
}
</style>

206
apps/web/src/oj/store/ai.ts Normal file
View File

@@ -0,0 +1,206 @@
import type { DetailsData, DurationData } from "utils/types"
import { consumeJSONEventStream } from "utils/stream"
import {
getAIDetailData,
getAIDurationData,
getAIHeatmapData,
getAIPinnedReport,
} from "../api"
import { getCSRFToken } from "utils/functions"
export const useAIStore = defineStore("ai", () => {
const duration = ref("months:6")
const targetUsername = ref("")
const durationData = ref<DurationData[]>([])
const detailsData = reactive<DetailsData>({
start: "",
end: "",
grade: "B",
class_name: "",
tags: {},
difficulty: {},
contest_count: 0,
solved: [],
flowcharts: [],
})
const heatmapData = ref<{ timestamp: number; value: number }[]>([])
const loading = reactive({
fetching: false, // 合并 details 和 duration 的 loading
ai: false,
heatmap: false,
})
const mdContent = ref("")
const pinnedReport = ref<{ analysis: string } | null>(null)
async function fetchDetailsData(start: string, end: string) {
const res = await getAIDetailData(
start,
end,
targetUsername.value || undefined,
)
detailsData.start = res.data.start
detailsData.end = res.data.end
detailsData.solved = res.data.solved
detailsData.grade = res.data.grade
detailsData.class_name = res.data.class_name
detailsData.tags = res.data.tags
detailsData.difficulty = res.data.difficulty
detailsData.contest_count = res.data.contest_count
detailsData.flowcharts = res.data.flowcharts
}
async function fetchDurationData(end: string, duration: string) {
const res = await getAIDurationData(
end,
duration,
targetUsername.value || undefined,
)
durationData.value = res.data
}
async function fetchHeatmapData() {
loading.heatmap = true
const res = await getAIHeatmapData(targetUsername.value || undefined)
heatmapData.value = res.data
loading.heatmap = false
}
async function fetchAnalysisData(
start: string,
end: string,
duration: string,
) {
loading.fetching = true
try {
await Promise.all([
fetchDetailsData(start, end),
fetchDurationData(end, duration),
])
} finally {
loading.fetching = false
}
}
let aiController: AbortController | null = null
async function fetchAIAnalysis() {
if (aiController) {
aiController.abort()
}
const controller = new AbortController()
aiController = controller
loading.ai = true
mdContent.value = ""
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
const csrfToken = getCSRFToken()
if (csrfToken) {
headers["X-CSRFToken"] = csrfToken
}
try {
const response = await fetch("/api/ai/analysis", {
method: "POST",
headers,
body: JSON.stringify({
details: detailsData,
duration: durationData.value,
}),
signal: controller.signal,
})
if (!response.ok) {
throw new Error("AI 分析生成失败")
}
let hasStarted = false
await consumeJSONEventStream(response, {
signal: controller.signal,
onEvent(event) {
if (event === "end" && !hasStarted) {
loading.ai = false
}
},
onMessage(payload) {
const parsed = payload as {
type?: string
content?: string
message?: string
}
if (parsed.type === "delta" && parsed.content) {
if (!hasStarted) {
hasStarted = true
loading.ai = false
}
mdContent.value += parsed.content
} else if (parsed.type === "error") {
throw new Error(parsed.message || "AI 服务异常")
} else if (parsed.type === "done" && !hasStarted) {
loading.ai = false
}
},
})
} catch (error: any) {
if (controller.signal.aborted) {
return
}
console.error("生成 AI 分析失败", error)
const message = error?.message || "生成失败,请稍后再试"
mdContent.value = `生成失败:${message}`
} finally {
if (aiController === controller) {
aiController = null
loading.ai = false
}
}
}
async function fetchPinnedReport() {
const res = await getAIPinnedReport()
pinnedReport.value = res.data
}
async function simulatePinnedStream() {
if (!pinnedReport.value) return
const text = pinnedReport.value.analysis
mdContent.value = ""
const CHUNK = 6
const DELAY = 18
await new Promise<void>((resolve) => {
let i = 0
function step() {
if (i >= text.length) {
resolve()
return
}
mdContent.value += text.slice(i, i + CHUNK)
i += CHUNK
setTimeout(step, DELAY)
}
step()
})
}
return {
fetchAnalysisData,
fetchHeatmapData,
fetchAIAnalysis,
fetchPinnedReport,
simulatePinnedStream,
durationData,
detailsData,
heatmapData,
duration,
targetUsername,
loading,
mdContent,
pinnedReport,
}
})

View File

@@ -0,0 +1,94 @@
import { defineStore } from "pinia"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import type { Code, LANGUAGE } from "utils/types"
/**
* 代码编辑器状态管理 Store
* 管理全局的代码、输入、输出状态
*/
export const useCodeStore = defineStore("code", () => {
// ==================== 状态 ====================
const code = reactive<Code>({
value: "",
language: storage.get(STORAGE_KEY.LANGUAGE) || "Python3",
})
const input = ref("")
const output = ref("")
// ==================== 计算属性 ====================
const isEmpty = computed(() => code.value.trim() === "")
// ==================== 操作 ====================
/**
* 设置代码内容
*/
function setCode(value: string) {
code.value = value
}
/**
* 设置编程语言
*/
function setLanguage(language: LANGUAGE) {
code.language = language
storage.set(STORAGE_KEY.LANGUAGE, language)
}
/**
* 设置输入
*/
function setInput(value: string) {
input.value = value
}
/**
* 设置输出
*/
function setOutput(value: string) {
output.value = value
}
/**
* 重置所有状态
*/
function reset() {
code.value = ""
input.value = ""
output.value = ""
}
/**
* 清空输出
*/
function clearOutput() {
output.value = ""
}
// 监听语言变化,保存到本地存储
watch(
() => code.language,
(newLanguage) => {
storage.set(STORAGE_KEY.LANGUAGE, newLanguage)
},
)
return {
// 状态
code,
input,
output,
// 计算属性
isEmpty,
// 操作
setCode,
setLanguage,
setInput,
setOutput,
reset,
clearOutput,
}
})

View File

@@ -0,0 +1,116 @@
import { formatISO, getTime, parseISO } from "date-fns"
import { useUserStore } from "shared/store/user"
import { ContestStatus, ContestType } from "utils/constants"
import { duration } from "utils/functions"
import type { Contest, Problem } from "utils/types"
import {
checkContestPassword,
getContest,
getContestAccess,
getContestProblems,
} from "../api"
export const useContestStore = defineStore("contest", () => {
const userStore = useUserStore()
const [access, toggleAccess] = useToggle(false)
const contest = ref<Contest | null>(null)
const problems = ref<Problem[]>([])
const now = ref(0)
let timer = 0
const contestStatus = computed<ContestStatus>(() => {
if (!contest.value) return ContestStatus.initial
const start = getTime(parseISO(contest.value.start_time.toString()))
const end = getTime(parseISO(contest.value.end_time.toString()))
if (start > now.value) {
return ContestStatus.not_started
} else if (end < now.value) {
return ContestStatus.finished
} else {
return ContestStatus.underway
}
})
const countdown = computed(() => {
if (contestStatus.value === ContestStatus.finished) {
return "已结束"
} else if (contestStatus.value === ContestStatus.not_started) {
const d = duration(formatISO(now.value), contest.value!.start_time, true)
return "距离比赛开始 " + d
} else {
const d = duration(formatISO(now.value), contest.value!.end_time, true)
return "距离比赛结束 " + d
}
})
const isContestAdmin = computed(
() =>
userStore.isSuperAdmin ||
(userStore.isAuthed &&
contest.value?.created_by.id === userStore.user!.id),
)
const isPrivate = computed(
() => contest.value!.contest_type === ContestType.private,
)
async function init(contestID: string) {
problems.value = []
const res = await getContest(contestID)
contest.value = res.data
now.value = getTime(parseISO(res.data.now))
if (contestStatus.value !== ContestStatus.finished) {
timer = setInterval(() => {
now.value = now.value + 1000
}, 1000)
}
if (contest.value?.contest_type === ContestType.private) {
const res = await getContestAccess(contestID)
toggleAccess(res.data.access)
}
_getProblems(contestID)
}
function clear() {
contest.value = null
problems.value = []
toggleAccess(false)
now.value = 0
if (timer) clearInterval(timer)
}
async function checkPassword(contestID: string, password: string) {
try {
const res = await checkContestPassword(contestID, password)
toggleAccess(res.data)
if (res.data) {
_getProblems(contestID)
}
} catch (err) {
toggleAccess(false)
}
}
async function _getProblems(contestID: string) {
try {
problems.value = await getContestProblems(contestID)
} catch (err) {
problems.value = []
toggleAccess(false)
}
}
return {
contest,
contestStatus,
isContestAdmin,
access,
problems,
isPrivate,
countdown,
init,
clear,
checkPassword,
}
})

View File

@@ -0,0 +1,34 @@
import { defineStore } from "pinia"
import type { LANGUAGE, Problem } from "utils/types"
export const useProblemStore = defineStore("problem", () => {
const problem = ref<Problem | null>(null)
const route = useRoute()
const failCount = ref(0)
const languages = computed<LANGUAGE[]>(() => {
if (route.name === "problem" && problem.value?.allow_flowchart) {
return ["Flowchart", ...problem.value?.languages]
}
return problem.value?.languages ?? []
})
function incrementFailCount() {
failCount.value++
}
watch(
() => problem.value?.id,
() => {
failCount.value = 0
},
)
return {
problem,
failCount,
languages,
incrementFailCount,
}
})

View File

@@ -0,0 +1,53 @@
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { USERNAME_CLASS_RE } from "utils/constants"
interface Props {
type: "题目" | "用户"
username?: string
}
const props = defineProps<Props>()
const emits = defineEmits(["click", "search", "filterClass"])
// 用同一个正则判断,避免 kstest 这类 ks 开头但没有班级号的用户名
// 也显示出按钮,点了却什么都不发生
const showFilterClass = computed(() => {
return props.type === "用户" && USERNAME_CLASS_RE.test(props.username ?? "")
})
function filterClass() {
const match = props.username!.match(USERNAME_CLASS_RE)
const classname = match ? match[0] : ""
if (!classname) return
emits("filterClass", classname)
}
</script>
<template>
<n-flex align="center">
<n-button text type="info" @click="$emit('click')">
<slot></slot>
</n-button>
<n-tooltip>
<template #trigger>
<n-button text @click="$emit('search')">
<template #icon>
<Icon icon="streamline-emojis:magnifying-glass-tilted-left"></Icon>
</template>
</n-button>
</template>
{{ "搜索" + props.type }}
</n-tooltip>
<n-tooltip v-if="showFilterClass">
<template #trigger>
<n-button text @click="filterClass">
<template #icon>
<Icon icon="ph:funnel"></Icon>
</template>
</n-button>
</template>
筛选班级
</n-tooltip>
</n-flex>
</template>

View File

@@ -0,0 +1,35 @@
<template>
<n-button v-if="showLink" type="info" text @click="handleClick">
{{ flowchart.id.slice(0, 12) }}
</n-button>
<n-text v-else class="flowchart-id" @click="handleClick">
{{ flowchart.id.slice(0, 12) }}
</n-text>
</template>
<script setup lang="ts">
import type { FlowchartSubmissionListItem } from "utils/types"
import { useUserStore } from "shared/store/user"
const userStore = useUserStore()
interface Props {
flowchart: FlowchartSubmissionListItem
}
const props = defineProps<Props>()
const emit = defineEmits<{
showDetail: [id: string]
}>()
const showLink = computed(() => {
if (!userStore.isAuthed) return false
if (userStore.isSuperAdmin) return true
return props.flowchart.username === userStore.user?.username
})
function handleClick() {
emit("showDetail", props.flowchart.id)
}
</script>
<style scoped></style>

View File

@@ -0,0 +1,226 @@
<template>
<n-grid v-if="submission" :cols="5" :x-gap="16">
<!-- 左侧流程图预览区域 -->
<n-gi :span="3">
<n-card title="流程图预览">
<template #header-extra>
<n-button
v-if="!renderError && submission?.mermaid_code"
quaternary
size="small"
@click="showLargeImage = true"
>
<template #icon>
<Icon icon="ph:corners-out" />
</template>
查看大图
</n-button>
</template>
<div class="flowchart">
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
{{ renderError }}
</n-alert>
<Teleport v-else to="body" :disabled="!showLargeImage">
<div
:class="['flowchart', { 'flowchart-fullscreen': showLargeImage }]"
ref="mermaidContainer"
></div>
<div v-if="showLargeImage" class="fullscreen-toolbar">
<n-button secondary round @click="showLargeImage = false">
<template #icon>
<Icon icon="ph:corners-in" />
</template>
退出大图
</n-button>
</div>
</Teleport>
</div>
</n-card>
</n-gi>
<!-- 右侧评分详情区域 -->
<n-gi :span="2">
<!-- AI反馈 -->
<n-card
v-if="submission.ai_feedback"
size="small"
title="AI反馈"
style="margin-bottom: 16px"
>
<n-text>{{ submission.ai_feedback }}</n-text>
</n-card>
<!-- 改进建议 -->
<n-card
v-if="suggestionLines.length"
size="small"
title="改进建议"
style="margin-bottom: 16px"
>
<n-flex vertical :size="6">
<n-text
v-for="(suggestion, index) in suggestionLines"
:key="`${index}-${suggestion}`"
>
{{ suggestion }}
</n-text>
</n-flex>
</n-card>
<!-- 详细评分 -->
<n-card
v-if="
submission.ai_criteria_details &&
Object.keys(submission.ai_criteria_details).length > 0
"
size="small"
title="详细评分"
>
<div
v-for="(detail, key) in submission.ai_criteria_details"
:key="key"
style="margin-bottom: 12px"
>
<!-- 评分项标题和分数 -->
<n-flex
justify="space-between"
align="center"
style="margin-bottom: 4px"
>
<n-text strong>{{ key }}</n-text>
<n-tag
:type="getPercentType(detail.score / detail.max)"
size="small"
round
>
{{ detail.score || 0 }}分 / {{ detail.max }}分
</n-tag>
</n-flex>
<!-- 评分项详细说明 -->
<n-text v-if="detail.comment" depth="3" style="font-size: 12px">
{{ detail.comment }}
</n-text>
</div>
</n-card>
</n-gi>
</n-grid>
<n-spin v-else :show="loading" class="loading-container"> </n-spin>
</template>
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { FlowchartSubmission } from "utils/types"
import { useMermaid } from "shared/composables/useMermaid"
interface Props {
submissionId: string
}
const props = defineProps<Props>()
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
const { renderError, renderFlowchart } = useMermaid()
const submission = ref<FlowchartSubmission | null>(null)
const loading = ref(false)
const rendering = ref(false)
const showLargeImage = ref(false)
const suggestionLines = computed(() =>
splitSuggestionLines(submission.value?.ai_suggestions),
)
function splitSuggestionLines(suggestions?: string | null) {
return suggestions
? suggestions
.split("\n")
.map((suggestion) => suggestion.trim())
.filter(Boolean)
: []
}
function getPercentType(percent: number) {
if (percent >= 0.8) return "primary"
else if (percent >= 0.6) return "info"
else if (percent >= 0.4) return "warning"
return "error"
}
async function loadSubmission() {
if (!props.submissionId) return
showLargeImage.value = false
loading.value = true
try {
const { getFlowchartSubmission } = await import("oj/api")
const res = await getFlowchartSubmission(props.submissionId)
submission.value = res.data
// 渲染流程图
if (submission.value?.mermaid_code) {
rendering.value = true
await nextTick()
await renderFlowchart(
mermaidContainer.value,
submission.value.mermaid_code,
)
rendering.value = false
}
} catch (error) {
console.error("Failed to load submission:", error)
} finally {
loading.value = false
}
}
watch(() => props.submissionId, loadSubmission, { immediate: true })
</script>
<style scoped>
.flowchart {
height: 500px;
display: flex;
justify-content: center;
align-items: center;
}
/* 全屏大图:覆盖整个视口,脱离弹框宽度限制 */
.flowchart-fullscreen {
position: fixed;
inset: 0;
z-index: 4000;
width: 100vw;
height: 100vh;
padding: 32px;
box-sizing: border-box;
background: #ffffff;
/* 改为可滚动块布局,超出视口的大图可以滚动查看 */
display: block;
overflow: auto;
}
.fullscreen-toolbar {
position: fixed;
top: 16px;
right: 16px;
z-index: 4001;
}
/* 确保 SVG 图表占满容器 */
:deep(.flowchart > svg) {
height: 100%;
}
/* 全屏时按自然尺寸显示并水平居中,配合容器滚动 */
:deep(.flowchart-fullscreen > svg) {
display: block;
margin: 0 auto;
width: auto;
height: auto;
max-width: none;
}
.loading-container {
min-height: 600px;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -0,0 +1,25 @@
<template>
<n-text :type="gradeType(grade)">
<span>{{ score }}</span>
<span>({{ grade }})</span>
</n-text>
</template>
<script setup lang="ts">
import type { Grade } from "utils/types"
defineProps<{
score: number
grade: Grade
}>()
function gradeType(grade: Grade) {
return (
{
S: "success",
A: "info",
B: "warning",
C: "error",
} as const
)[grade]
}
</script>
<style scoped></style>

View File

@@ -0,0 +1,52 @@
<template>
<n-flex v-if="props.submission.show_link" align="center">
<n-button text type="info" @click="$emit('showCode')">
{{ props.submission.id.slice(0, 12) }}
</n-button>
<n-tooltip>
<template #trigger>
<n-button text @click="goto">
<template #icon>
<Icon icon="catppuccin:folder-debug"></Icon>
</template>
</n-button>
</template>
查看测试详情
</n-tooltip>
</n-flex>
<n-flex v-else-if="isOwnSubmission" align="center">
<span>{{ props.submission.id.slice(0, 12) }}</span>
<n-tooltip>
<template #trigger>
<n-button text>
<template #icon>
<Icon icon="catppuccin:lock"></Icon>
</template>
</n-button>
</template>
这道题在你已经加入的题单中只有在题单中完成此题代码才可见
</n-tooltip>
</n-flex>
<span v-else>{{ props.submission.id.slice(0, 12) }}</span>
</template>
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useUserStore } from "shared/store/user"
import type { SubmissionListItem } from "utils/types"
interface Props {
submission: SubmissionListItem
}
const props = defineProps<Props>()
defineEmits(["showCode"])
const userStore = useUserStore()
const isOwnSubmission = computed(
() => userStore.profile?.user?.id === props.submission.user_id,
)
function goto() {
window.open("/submission/" + props.submission.id, "_blank")
}
</script>

View File

@@ -0,0 +1,168 @@
<script setup lang="ts">
import { getSubmission } from "oj/api"
import {
JUDGE_STATUS,
LANGUAGE_FORMAT_VALUE,
LANGUAGE_SHOW_VALUE,
} from "utils/constants"
import {
parseTime,
submissionMemoryFormat,
submissionTimeFormat,
utoa,
} from "utils/functions"
import type { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useCodeStore } from "oj/store/code"
import storage from "utils/storage"
const props = defineProps<{
submissionID: string
problemID?: string
submission?: Submission
hideList?: boolean
}>()
// 在弹框中使用时,父组件监听此事件关闭弹框,否则弹框会挡住已更新的编辑器
const emit = defineEmits<{ copied: [] }>()
const route = useRoute()
const router = useRouter()
const codeStore = useCodeStore()
const { isMobile, isDesktop } = useBreakpoints()
const submission = ref<Submission>()
const loading = ref(false)
async function init() {
submission.value = props.submission
if (submission.value) return
loading.value = true
const res = await getSubmission(props.submissionID)
submission.value = res.data
loading.value = false
}
const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
{ title: "测试用例", key: "test_case" },
{
title: "测试状态",
key: "result",
render: (row) => h(SubmissionResultTag, { result: row.result }),
},
{
title: "占用内存",
key: "memory",
render: (row) => submissionMemoryFormat(row.memory),
},
{
title: "执行耗时",
key: "real_time",
render: (row) => submissionTimeFormat(row.real_time),
},
]
function copyToCat() {
const lang = LANGUAGE_FORMAT_VALUE[submission.value!.language]
const data = {
lang,
code: submission.value!.code,
input: "",
}
const base64 = utoa(JSON.stringify(data))
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
window.open(url, "_blank")
}
function copyToProblem() {
const { code, language, contest } = submission.value!
// 编辑器的 storageKey 用 display idproblem._id等于 props.problemID
// 而非 submission.problem内部数字 id
const contestIDForKey = contest || null
const storageKey = `problem_${props.problemID}_contest_${contestIDForKey}_lang_${language}`
storage.set(storageKey, code)
// 设置语言 + 代码localStorage 覆盖全新挂载的编辑器,
// setCode 覆盖已挂载(同页 modal的编辑器
codeStore.setLanguage(language)
codeStore.setCode(code)
const problemSetId = (route.params.problemSetId as string) ?? ""
if (contest) {
router.push({
name: "contest problem",
params: { contestID: String(contest), problemID: props.problemID },
})
} else if (problemSetId) {
router.push({
name: "problemset problem",
params: { problemSetId, problemID: props.problemID },
})
} else {
router.push({
name: "problem",
params: { problemID: props.problemID },
})
}
emit("copied")
}
onMounted(init)
</script>
<template>
<n-flex vertical v-if="submission" :size="24">
<n-flex :vertical="isMobile" justify="space-between">
<n-alert
style="flex: 1"
:type="JUDGE_STATUS[submission.result]['type']"
:title="JUDGE_STATUS[submission.result]['title']"
>
<n-flex>
<span>提交时间{{ parseTime(submission.create_time) }}</span>
<span>编程语言{{ LANGUAGE_SHOW_VALUE[submission.language] }}</span>
<span>用户{{ submission.username }}</span>
</n-flex>
</n-alert>
<n-flex :vertical="isDesktop" justify="center">
<n-button
v-if="submission.language !== 'SQL'"
secondary
@click="copyToCat"
>
复制到自测猫
</n-button>
<n-button secondary @click="copyToProblem">复制回到题目</n-button>
</n-flex>
</n-flex>
<n-card embedded>
<n-code
class="code"
:language="LANGUAGE_FORMAT_VALUE[submission.language]"
:code="submission.code"
show-line-numbers
/>
</n-card>
<n-data-table
v-if="!hideList && submission.info && submission.info.data"
:columns="columns"
:data="submission.info.data"
/>
</n-flex>
<n-spin v-else :show="loading" class="loading-container"> </n-spin>
</template>
<style scoped>
.code {
font-size: 20px;
overflow: auto;
}
.loading-container {
min-height: 200px;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -0,0 +1,573 @@
<script setup lang="ts">
import { NButton } from "naive-ui"
import { useRouteQuery } from "@vueuse/router"
import {
adminRejudge,
getFlowchartSubmissions,
getSubmissions,
getTodaySubmissionCount,
retryFlowchartSubmission,
} from "oj/api"
import { parseTime } from "utils/functions"
import type {
FlowchartSubmissionListItem,
LANGUAGE,
SubmissionListItem,
} from "utils/types"
import Pagination from "shared/components/Pagination.vue"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { usePagination } from "shared/composables/pagination"
import { useUserStore } from "shared/store/user"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import { renderTableTitle } from "utils/renders"
import ButtonWithSearch from "./components/ButtonWithSearch.vue"
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
import FlowchartStatisticsPanel from "shared/components/FlowchartStatisticsPanel.vue"
import SubmissionLink from "./components/SubmissionLink.vue"
import SubmissionDetail from "./detail.vue"
import Grade from "./components/Grade.vue"
import FlowchartLink from "./components/FlowchartLink.vue"
import FlowchartScoreDetail from "./components/FlowchartScoreDetail.vue"
interface SubmissionQuery {
username: string
result: string
myself: "0" | "1"
problem: string
language: LANGUAGE | ""
today: "0" | "1"
}
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const message = useMessage()
const { isMobile, isDesktop } = useBreakpoints()
const submissions = ref<SubmissionListItem[]>([])
const flowcharts = ref<FlowchartSubmissionListItem[]>([])
const total = ref(0)
const todayCount = ref(0)
// 使用分页 composable
const { query, clearQuery } = usePagination<SubmissionQuery>({
username: useRouteQuery("username", "").value,
result: useRouteQuery("result", "").value,
myself: useRouteQuery("myself", "0").value,
problem: useRouteQuery("problem", "").value,
language: useRouteQuery("language", "").value,
today: "0",
})
const submissionID = ref("")
const problemDisplayID = ref("")
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
const [codePanel, toggleCodePanel] = useToggle(false)
const [scoreDetailPanel, toggleScoreDetailPanel] = useToggle(false)
const selectedFlowchartId = ref("")
const selectedFlowchart = computed(() => {
return flowcharts.value.find((f) => f.id === selectedFlowchartId.value)
})
const resultOptions: SelectOption[] = [
{ label: "全部", value: "" },
{ label: "答案正确", value: "0" },
{ label: "语法未通过", value: "10" },
{ label: "答案错误", value: "-1" },
{ label: "编译失败", value: "-2" },
{ label: "运行时错误", value: "4" },
]
const gradeOptions: SelectOption[] = [
{ label: "全部", value: "" },
{ label: "S级", value: "S" },
{ label: "A级", value: "A" },
{ label: "B级", value: "B" },
{ label: "C级", value: "C" },
]
const languageOptions: SelectOption[] = [
{ label: "流程图", value: "Flowchart" },
{ label: "全部语言", value: "" },
{ label: "Python", value: "Python3" },
{ label: "C语言", value: "C" },
{ label: "C++", value: "C++" },
]
async function listSubmissions() {
if (query.page < 1) query.page = 1
const offset = query.limit * (query.page - 1)
if (query.language === "Flowchart") {
const res = await getFlowchartSubmissions({
username: query.username,
problem_id: query.problem,
myself: query.myself,
offset,
limit: query.limit,
today: query.today,
grade: query.result,
})
total.value = res.data.total
flowcharts.value = res.data.results
} else {
const res = await getSubmissions({
...query,
offset,
problem_id: query.problem,
contest_id: (route.params.contestID as string) ?? "",
language: query.language,
today: query.today,
})
submissions.value = res.data.results
total.value = res.data.total
}
}
async function getTodayCount() {
const res = await getTodaySubmissionCount(query.language)
todayCount.value = res.data
}
onMounted(() => {
listSubmissions()
if (route.name === "submissions") {
getTodayCount()
}
})
function search(username: string, problem: string) {
query.username = username
query.problem = problem
}
function clear() {
clearQuery()
}
async function rejudge(submissionID: string) {
await adminRejudge(submissionID)
message.success("重新判分成功")
listSubmissions()
}
async function retryFlowchart(submissionId: string) {
await retryFlowchartSubmission(submissionId)
message.success("重新评分已提交")
listSubmissions()
}
function problemClicked(row: SubmissionListItem | FlowchartSubmissionListItem) {
if (route.name === "contest submissions") {
const path = router.resolve({
name: "contest problem",
params: {
problemID: row.problem,
},
})
window.open(path.href, "_blank")
} else {
window.open("/problem/" + row.problem, "_blank")
}
}
function showCodePanel(id: string, problem: string) {
toggleCodePanel(true)
submissionID.value = id
problemDisplayID.value = problem
}
function showScoreDetail(id: string) {
selectedFlowchartId.value = id
toggleScoreDetailPanel(true)
}
function getGradeType(grade?: string) {
if (!grade) return "default"
if (grade === "S") return "primary"
if (grade === "A") return "info"
if (grade === "B") return "warning"
return "error"
}
// 监听用户名和题号变化(防抖)
watchDebounced(() => [query.username, query.problem], listSubmissions, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(
() => [
query.page,
query.limit,
query.myself,
query.result,
query.language,
query.today,
],
listSubmissions,
)
// 切换语言时重置过滤条件,刷新今日提交数
watch(
() => query.language,
() => {
query.result = ""
if (route.name === "submissions") getTodayCount()
},
)
// 登录状态变化后刷新提交列表,更新提交编号列的可点击状态
watch(
() => userStore.isAuthed,
() => {
listSubmissions()
if (route.name === "submissions") getTodayCount()
},
)
const columns = computed(() => {
const res: DataTableColumn<SubmissionListItem>[] = [
{
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
key: "create_time",
minWidth: 200,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
key: "id",
minWidth: 200,
render: (row) =>
h(SubmissionLink, {
submission: row,
onShowCode: () => showCodePanel(row.id, row.problem),
}),
},
{
title: renderTableTitle("状态", "streamline-emojis:panda-face"),
key: "status",
minWidth: 140,
render: (row) => h(SubmissionResultTag, { result: row.result }),
},
{
title: renderTableTitle("题目", "streamline-emojis:blossom"),
key: "problem",
minWidth: 300,
render: (row) =>
h(
ButtonWithSearch,
{
type: "题目",
onClick: () => problemClicked(row),
onSearch: () => (query.problem = row.problem),
},
() => `${row.problem} ${row.problem_title}`,
),
},
{
title: renderTableTitle("语言", "streamline-ultimate-color:earth-pin-2"),
key: "language",
minWidth: 120,
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
},
{
title: renderTableTitle(
"用户",
"streamline-emojis:smiling-face-with-sunglasses",
),
key: "username",
minWidth: 200,
render: (row) =>
h(
ButtonWithSearch,
{
type: "用户",
username: row.username,
onClick: () => window.open("/user?name=" + row.username, "_blank"),
onSearch: () => (query.username = row.username),
onFilterClass: (classname: string) => (query.username = classname),
},
() => row.username,
),
},
]
if (!route.params.contestID && userStore.isTeacherOrAbove) {
res.push({
title: renderTableTitle("选项", "streamline-emojis:wrench"),
key: "rejudge",
render: (row) =>
h(
NButton,
{
quaternary: true,
size: "small",
type: "primary",
onClick: () => rejudge(row.id),
},
() => "重新判题",
),
})
}
return res
})
const flowchartColumns = computed(() => {
const res: DataTableColumn<FlowchartSubmissionListItem>[] = [
{
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
key: "create_time",
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
key: "id",
render: (row) =>
h(FlowchartLink, {
flowchart: row,
onShowDetail: (id: string) => showScoreDetail(id),
}),
},
{
title: renderTableTitle("题目", "streamline-emojis:blossom"),
key: "problem_title",
render: (row) =>
h(
ButtonWithSearch,
{
type: "题目",
onClick: () => problemClicked(row),
onSearch: () => (query.problem = row.problem),
},
() => `${row.problem} ${row.problem_title}`,
),
},
{
title: renderTableTitle(
"评分",
"streamline-ultimate-color:analytics-bars-3d",
),
key: "ai_score",
render: (row) => h(Grade, { score: row.ai_score, grade: row.ai_grade }),
},
{
title: renderTableTitle(
"用户",
"streamline-emojis:smiling-face-with-sunglasses",
),
key: "username",
minWidth: 200,
render: (row) =>
h(
ButtonWithSearch,
{
type: "用户",
username: row.username,
onClick: () => window.open("/user?name=" + row.username, "_blank"),
onSearch: () => (query.username = row.username),
onFilterClass: (classname: string) => (query.username = classname),
},
() => row.username,
),
},
]
if (!route.params.contestID && userStore.isTeacherOrAbove) {
res.push({
title: renderTableTitle("选项", "streamline-emojis:wrench"),
key: "retry",
render: (row) =>
h(
NButton,
{
quaternary: true,
size: "small",
type: "primary",
onClick: () => retryFlowchart(row.id),
},
() => "重新判题",
),
})
}
return res
})
</script>
<template>
<n-flex vertical size="large">
<n-space>
<n-form :show-feedback="false" inline label-placement="left">
<n-form-item v-if="isDesktop && userStore.isAuthed" label="只看自己">
<n-switch
v-model:value="query.myself"
checked-value="1"
unchecked-value="0"
/>
</n-form-item>
<n-form-item label="语言" v-if="route.name !== 'contest submissions'">
<n-select
class="select"
v-model:value="query.language"
:options="languageOptions"
/>
</n-form-item>
<n-form-item :label="query.language === 'Flowchart' ? '等级' : '状态'">
<n-select
class="select"
v-model:value="query.result"
:options="
query.language === 'Flowchart' ? gradeOptions : resultOptions
"
/>
</n-form-item>
</n-form>
<n-form :show-feedback="false" inline label-placement="left">
<n-form-item>
<n-input
:disabled="query.myself === '1'"
style="width: 140px"
clearable
v-model:value="query.username"
placeholder="用户"
/>
</n-form-item>
<n-form-item>
<n-input
style="width: 120px"
clearable
v-model:value="query.problem"
placeholder="题号"
/>
</n-form-item>
</n-form>
<n-form :show-feedback="false" inline label-placement="left">
<n-form-item v-if="isMobile && userStore.isAuthed" label="只看自己">
<n-switch
v-model:value="query.myself"
checked-value="1"
unchecked-value="0"
/>
</n-form-item>
<n-form-item>
<n-button @click="search(query.username, query.problem)">
搜索
</n-button>
</n-form-item>
<n-form-item>
<n-button @click="clear" quaternary>重置</n-button>
</n-form-item>
<n-form-item
v-if="userStore.isTeacherOrAbove && route.name === 'submissions'"
>
<n-button
quaternary
type="warning"
@click="toggleStatisticPanel(true)"
>
数据统计
</n-button>
</n-form-item>
</n-form>
<n-tag
v-if="todayCount > 0"
checkable
:checked="query.today === '1'"
type="success"
size="large"
@update:checked="(v: boolean) => (query.today = v ? '1' : '0')"
>
<n-gradient-text v-if="query.today !== '1'" type="success">
今日提交数{{ todayCount }}
</n-gradient-text>
<template v-else>今日提交数{{ todayCount }}</template>
</n-tag>
</n-space>
<n-data-table
v-if="query.language === 'Flowchart'"
:bordered="false"
:columns="flowchartColumns"
:data="flowcharts"
/>
<n-data-table
v-else
:bordered="false"
:columns="columns"
:data="submissions"
/>
</n-flex>
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
<n-modal
v-if="userStore.isTeacherOrAbove"
v-model:show="statisticPanel"
preset="card"
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
:title="
query.language === 'Flowchart' ? '流程图提交的统计' : '提交记录的统计'
"
>
<FlowchartStatisticsPanel
v-if="query.language === 'Flowchart'"
:problem="query.problem"
:username="query.username"
/>
<StatisticsPanel
v-else
:problem="query.problem"
:username="query.username"
/>
</n-modal>
<n-modal
v-model:show="codePanel"
preset="card"
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
title="代码详情"
>
<SubmissionDetail
:problemID="problemDisplayID"
:submissionID="submissionID"
hideList
@copied="toggleCodePanel(false)"
/>
</n-modal>
<n-modal
v-model:show="scoreDetailPanel"
preset="card"
:style="{ maxWidth: isDesktop && '1000px', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
>
<template #header>
<n-flex align="center">
<n-text>流程图评分详情</n-text>
<n-text
v-if="selectedFlowchart"
:type="getGradeType(selectedFlowchart.ai_grade)"
>
{{ selectedFlowchart.ai_score }} {{ selectedFlowchart.ai_grade }}
</n-text>
</n-flex>
</template>
<FlowchartScoreDetail :submissionId="selectedFlowchartId" />
</n-modal>
</template>
<style scoped>
.select {
width: 120px;
}
.code {
font-size: 20px;
overflow: auto;
}
.flowchart-iframe {
width: 100%;
height: 100%;
border: none;
display: block;
}
</style>

View File

@@ -0,0 +1,29 @@
import { DIFFICULTY } from "utils/constants"
import { getACRate } from "utils/functions"
import type { Problem } from "utils/types"
// 把后端的 Problem 塑形成列表项需要的形状,与请求逻辑解耦。
export function filterResult(result: Problem) {
const newResult = {
id: result.id,
_id: result._id,
title: result.title,
difficulty: DIFFICULTY[result.difficulty],
tags: result.tags,
submission: result.submission_number,
rate: getACRate(result.accepted_number, result.submission_number),
status: "",
author: result.created_by.username,
allow_flowchart: result.allow_flowchart,
show_flowchart: result.show_flowchart,
has_ast_rules: result.has_ast_rules,
}
if (result.my_status === null || result.my_status === undefined) {
newResult.status = "not_test"
} else if (result.my_status === 0) {
newResult.status = "passed"
} else {
newResult.status = "failed"
}
return newResult
}

View File

@@ -0,0 +1,331 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { NH2, NH3 } from "naive-ui"
import { getProfile } from "shared/api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { durationToDays, parseTime } from "utils/functions"
import type { AchievementSummary, Profile } from "utils/types"
import { getAchievementSummary } from "oj/achievement/api"
import { getMetrics } from "../api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useUserStore } from "shared/store/user"
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const profile = ref<Profile | null>(null)
const problems = ref<string[]>([])
const firstSubmissionAt = ref("")
const latestSubmissionAt = ref("")
const toLatestAt = ref("")
const learnDuration = ref("")
const achievementSummary = ref<AchievementSummary | null>(null)
const [loading, toggle] = useToggle()
const [show, toggleShow] = useToggle(false)
const { isDesktop } = useBreakpoints()
const isDefaultAvatar = computed(
() => profile.value?.avatar.endsWith("default.png") ?? true,
)
const problemsFlexRef = useTemplateRef<HTMLElement>("problemsFlexRef")
const itemsPerRow = ref(8)
function updateItemsPerRow() {
if (!problemsFlexRef.value) return
const buttons = problemsFlexRef.value.querySelectorAll("button")
if (!buttons.length) return
const firstTop = buttons[0].offsetTop
let count = 0
for (const btn of buttons) {
if (btn.offsetTop === firstTop) count++
else break
}
if (count > 0) itemsPerRow.value = count
}
useResizeObserver(problemsFlexRef, updateItemsPerRow)
watch(problems, async () => {
await nextTick()
updateItemsPerRow()
})
const visibleProblems = computed(() =>
show.value ? problems.value : problems.value.slice(0, itemsPerRow.value * 3),
)
const hasMoreProblems = computed(
() => problems.value.length > itemsPerRow.value * 3,
)
async function init() {
toggle(true)
try {
const res = await getProfile(route.query.name as string)
profile.value = res.data
const acm = res.data.acm_problems_status.problems || {}
const ac: string[] = []
Object.keys(acm).forEach((id) => {
if (acm[id]["status"] === 0) {
ac.push(acm[id]["_id"])
}
})
ac.sort()
problems.value = ac
if (profile.value.submission_number > 0) {
const metricsRes = await getMetrics(profile.value.user.id)
firstSubmissionAt.value = parseTime(metricsRes.data.first)
latestSubmissionAt.value = parseTime(metricsRes.data.latest)
toLatestAt.value = durationToDays(
metricsRes.data.latest,
metricsRes.data.now,
)
learnDuration.value = durationToDays(
metricsRes.data.first,
metricsRes.data.latest,
)
}
} finally {
toggle(false)
}
}
// 单独取,不塞进上面的 promises 数组:那里是按位置取 results[0]/[1] 的,
// 插一项进去会打乱既有索引。成就摘要取不到也不该影响整个个人主页
async function loadAchievementSummary() {
try {
const res = await getAchievementSummary(
(route.query.name as string) || undefined,
)
achievementSummary.value = res.data
} catch {
achievementSummary.value = null
}
}
const metrics = computed(() => {
if (loading.value) return []
return [
{
icon: "fluent-emoji:face-with-peeking-eye",
title: learnDuration.value,
content: "总共学习天数",
},
{
icon: "fluent-emoji:cheese-wedge",
title: toLatestAt.value,
content: "距离上次提交",
},
{
icon: "fluent-emoji:dog-face",
title: latestSubmissionAt.value,
content: "最新一次提交时间",
},
{
icon: "fluent-emoji:cat-with-wry-smile",
title: firstSubmissionAt.value,
content: "第一次提交时间",
},
{
icon: "fluent-emoji:candy",
title: profile.value?.accepted_number ?? 0,
content: "已解决的题目数量",
animate: true,
},
{
icon: "fluent-emoji:thinking-face",
title: profile.value?.submission_number ?? 0,
content: "总提交数量",
animate: true,
},
]
})
onMounted(() => {
init()
loadAchievementSummary()
})
</script>
<template>
<n-flex
class="wrapper"
vertical
justify="center"
align="center"
v-if="!loading && profile"
>
<n-image
:width="140"
:height="140"
:src="profile.avatar"
:preview-disabled="isDefaultAvatar"
object-fit="cover"
:style="{
borderRadius: '50%',
overflow: 'hidden',
cursor: isDefaultAvatar ? 'default' : 'pointer',
}"
/>
<h2>{{ profile.user.username }}</h2>
<p class="desc">{{ profile.mood }}</p>
<n-button
v-if="userStore.isSuperAdmin"
type="info"
secondary
@click="
router.push({
name: 'ai',
query: { username: profile.user.username, duration: 'months:6' },
})
"
>
智能分析
</n-button>
</n-flex>
<n-grid
v-if="profile && profile.submission_number > 0"
class="wrapper"
:cols="isDesktop ? 2 : 1"
:x-gap="10"
:y-gap="10"
>
<n-gi v-for="item in metrics" :key="item.content">
<n-card hoverable>
<n-flex align="center">
<Icon :icon="item.icon" :width="isDesktop ? 50 : 40" />
<div>
<Component :is="isDesktop ? NH2 : NH3" class="number">
<n-number-animation v-if="item.animate" :to="item.title" />
<template v-else>
{{ item.title }}
</template>
</Component>
<n-h4 class="number-label">{{ item.content }}</n-h4>
</div>
</n-flex>
</n-card>
</n-gi>
</n-grid>
<!-- 成就摘要 -->
<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-flex>
<n-button
text
type="primary"
@click="
router.push({
path: '/achievement',
query: route.query.name ? { name: route.query.name } : {},
})
"
>
查看全部
</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-tooltip v-for="a in achievementSummary.recent" :key="a.id">
<template #trigger>
<span class="achievement-icon">
<AchievementIcon :icon="a.icon" :size="24" />
</span>
</template>
{{ a.name }}
</n-tooltip>
<n-text v-if="!achievementSummary.recent.length" depth="3">
还没有获得成就
</n-text>
</n-flex>
</n-card>
<n-descriptions v-if="!loading && profile" class="wrapper" bordered>
<n-descriptions-item v-if="!!problems.length">
<template #label>
<n-flex justify="space-between" align="center">
<span>已解决的题目</span>
<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)"
>
{{ id }}
</n-button>
</n-flex>
</div>
</n-descriptions-item>
</n-descriptions>
<n-empty v-if="!loading && !profile" description="该用户不存在">
<template #extra>
<n-button @click="router.push('/')">返回主页</n-button>
</template>
</n-empty>
</template>
<style scoped>
.wrapper {
max-width: 610px;
margin: 16px auto 0;
}
.number {
margin-bottom: 0;
font-weight: bold;
}
.number-label {
margin: 0;
}
h2 {
margin: 0;
font-weight: normal;
}
.desc {
margin: 0 auto;
word-wrap: break-word;
max-width: 100%;
}
.achievement-title {
font-weight: 600;
}
.achievement-recent {
margin-top: 10px;
}
.achievement-icon {
display: inline-flex;
align-items: center;
font-size: 24px;
cursor: default;
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<n-list v-if="messages.length">
<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>
<div>{{ parseTime(item.create_time, "YYYY年M月D日 HH:mm:ss") }}</div>
<div>发送者</div>
<div>{{ item.sender.username }}</div>
</n-flex>
<n-flex align="center">
<div>题目序号</div>
<n-button
text
type="info"
@click="router.push('/problem/' + item.submission.problem)"
>
{{ item.submission.problem }}
</n-button>
<n-text :type="JUDGE_STATUS[item.submission.result]['type']">
{{ JUDGE_STATUS[item.submission.result]["name"] }}
</n-text>
<Copy :value="item.submission.code" />
</n-flex>
<n-code
:language="LANGUAGE_FORMAT_VALUE[item.submission.language]"
:code="item.submission.code"
show-line-numbers
/>
<div v-html="item.message"></div>
</n-flex>
</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"
/>
</template>
<script lang="ts" setup>
import { getMessageList } from "oj/api"
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from "utils/constants"
import Copy from "shared/components/Copy.vue"
import Pagination from "shared/components/Pagination.vue"
import { parseTime } from "utils/functions"
import type { Message } from "utils/types"
const router = useRouter()
const messages = ref<Message[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
})
async function listMessages() {
const offset = (query.page - 1) * query.limit
const res = await getMessageList(offset, query.limit)
total.value = res.data.total
messages.value = res.data.results
}
onMounted(listMessages)
watch(query, listMessages, { deep: true })
</script>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { updateProfile, uploadAvatar } from "oj/api"
import { useUserStore } from "shared/store/user"
const userStore = useUserStore()
const message = useMessage()
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 啊")
return false
}
return true
}
async function upload({ file }: UploadCustomRequestOptions) {
try {
await uploadAvatar(file.file!)
message.success("上传成功")
userStore.getMyProfile()
} catch (err) {
message.error("上传失败")
}
}
async function saveProfile() {
try {
await updateProfile({
real_name: userStore.profile?.real_name ?? "",
mood: userStore.profile?.mood ?? "",
})
message.success("更改成功")
} catch (err) {
message.error("更改失败")
}
}
</script>
<template>
<n-flex class="container" vertical v-if="userStore.profile">
<h3>个人信息设置</h3>
<n-form>
<n-avatar round :size="120" :src="userStore.profile.avatar" alt="头像" />
<n-form-item label="">
<n-upload
:show-file-list="false"
accept="image/*"
@before-upload="beforeUpload"
:custom-request="upload"
>
<n-button>上传头像</n-button>
</n-upload>
</n-form-item>
<!-- <n-form-item label="真名">
<n-input v-model:value="userStore.profile.real_name" />
</n-form-item> -->
<n-form-item label="个性签名">
<n-input v-model:value="userStore.profile.mood" />
</n-form-item>
<n-button @click="saveProfile">更改信息</n-button>
</n-form>
</n-flex>
</template>
<style scoped>
.container {
max-width: 600px;
margin: 0 auto;
}
h3 {
font-weight: normal;
}
</style>