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