add leaderboard
This commit is contained in:
1
components.d.ts
vendored
1
components.d.ts
vendored
@@ -37,6 +37,7 @@ declare module 'vue' {
|
|||||||
NPagination: typeof import('naive-ui')['NPagination']
|
NPagination: typeof import('naive-ui')['NPagination']
|
||||||
NPopover: typeof import('naive-ui')['NPopover']
|
NPopover: typeof import('naive-ui')['NPopover']
|
||||||
NRate: typeof import('naive-ui')['NRate']
|
NRate: typeof import('naive-ui')['NRate']
|
||||||
|
NSelect: typeof import('naive-ui')['NSelect']
|
||||||
NSpin: typeof import('naive-ui')['NSpin']
|
NSpin: typeof import('naive-ui')['NSpin']
|
||||||
NSplit: typeof import('naive-ui')['NSplit']
|
NSplit: typeof import('naive-ui')['NSplit']
|
||||||
NTab: typeof import('naive-ui')['NTab']
|
NTab: typeof import('naive-ui')['NTab']
|
||||||
|
|||||||
17
src/api.ts
17
src/api.ts
@@ -67,6 +67,11 @@ export const Account = {
|
|||||||
const res = await http.post("/account/batch", payload)
|
const res = await http.post("/account/batch", payload)
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async leaderboard() {
|
||||||
|
const res = await http.get("/account/leaderboard")
|
||||||
|
return res.data as { rank: number; username: string; total_score: number }[]
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Tutorial = {
|
export const Tutorial = {
|
||||||
@@ -172,6 +177,18 @@ export const Submission = {
|
|||||||
const res = await http.put(`/submission/${id}/flag`, { flag })
|
const res = await http.put(`/submission/${id}/flag`, { flag })
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async myScores() {
|
||||||
|
const res = await http.get("/submission/my-scores")
|
||||||
|
return res.data as {
|
||||||
|
task_id: number
|
||||||
|
task_display: number
|
||||||
|
task_title: string
|
||||||
|
score: number
|
||||||
|
created: string
|
||||||
|
}[]
|
||||||
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Prompt = {
|
export const Prompt = {
|
||||||
|
|||||||
@@ -19,6 +19,14 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<n-tag type="warning" size="small">{{ item.score }}分</n-tag>
|
<n-tag type="warning" size="small">{{ item.score }}分</n-tag>
|
||||||
|
<n-tag
|
||||||
|
v-if="myScoreMap.get(item.display)"
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
style="margin-left: 4px"
|
||||||
|
>
|
||||||
|
得分 {{ myScoreMap.get(item.display)!.toFixed(1) }}
|
||||||
|
</n-tag>
|
||||||
</template>
|
</template>
|
||||||
</n-card>
|
</n-card>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
@@ -31,7 +39,7 @@ import { ref, onMounted } from "vue"
|
|||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { marked } from "marked"
|
import { marked } from "marked"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
import { Challenge } from "../api"
|
import { Challenge, Submission } from "../api"
|
||||||
import { taskTab, taskId, challengeDisplay } from "../store/task"
|
import { taskTab, taskId, challengeDisplay } from "../store/task"
|
||||||
import { TASK_TYPE } from "../utils/const"
|
import { TASK_TYPE } from "../utils/const"
|
||||||
import type { ChallengeSlim } from "../utils/type"
|
import type { ChallengeSlim } from "../utils/type"
|
||||||
@@ -40,6 +48,16 @@ const router = useRouter()
|
|||||||
const challenges = ref<ChallengeSlim[]>([])
|
const challenges = ref<ChallengeSlim[]>([])
|
||||||
const currentChallenge = ref<ChallengeSlim | null>(null)
|
const currentChallenge = ref<ChallengeSlim | null>(null)
|
||||||
const content = ref("")
|
const content = ref("")
|
||||||
|
const myScoreMap = ref<Map<number, number>>(new Map())
|
||||||
|
|
||||||
|
async function loadMyScores() {
|
||||||
|
try {
|
||||||
|
const scores = await Submission.myScores()
|
||||||
|
myScoreMap.value = new Map(scores.map((s) => [s.task_display, s.score]))
|
||||||
|
} catch {
|
||||||
|
// 未登录时忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadList() {
|
async function loadList() {
|
||||||
challenges.value = await Challenge.listDisplay()
|
challenges.value = await Challenge.listDisplay()
|
||||||
@@ -72,7 +90,10 @@ function back() {
|
|||||||
router.push({ name: "home-challenge-list" })
|
router.push({ name: "home-challenge-list" })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadList)
|
onMounted(async () => {
|
||||||
|
await loadList()
|
||||||
|
await loadMyScores()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.container {
|
.container {
|
||||||
|
|||||||
@@ -44,6 +44,12 @@
|
|||||||
>
|
>
|
||||||
<Icon :width="16" icon="lucide:list"></Icon>
|
<Icon :width="16" icon="lucide:list"></Icon>
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<n-button text @click="$router.push({ name: 'leaderboard' })">
|
||||||
|
<Icon :width="16" icon="lucide:trophy" />
|
||||||
|
</n-button>
|
||||||
|
<n-button text v-if="isLoggedIn" @click="$router.push({ name: 'my-scores' })">
|
||||||
|
<Icon :width="16" icon="lucide:bar-chart-2" />
|
||||||
|
</n-button>
|
||||||
<n-button text v-if="roleSuper" @click="edit">
|
<n-button text v-if="roleSuper" @click="edit">
|
||||||
<Icon :width="16" icon="lucide:edit"></Icon>
|
<Icon :width="16" icon="lucide:edit"></Icon>
|
||||||
</n-button>
|
</n-button>
|
||||||
@@ -63,7 +69,7 @@ import { step } from "../store/tutorial"
|
|||||||
import { roleSuper } from "../store/user"
|
import { roleSuper } from "../store/user"
|
||||||
import { taskTab, challengeDisplay } from "../store/task"
|
import { taskTab, challengeDisplay } from "../store/task"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { TASK_TYPE } from "../utils/const"
|
import { TASK_TYPE, STORAGE_KEY } from "../utils/const"
|
||||||
import Challenge from "./Challenge.vue"
|
import Challenge from "./Challenge.vue"
|
||||||
import Tutorial from "./Tutorial.vue"
|
import Tutorial from "./Tutorial.vue"
|
||||||
|
|
||||||
@@ -73,6 +79,8 @@ const tutorialRef = ref<InstanceType<typeof Tutorial>>()
|
|||||||
|
|
||||||
defineEmits(["hide"])
|
defineEmits(["hide"])
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => localStorage.getItem(STORAGE_KEY.LOGIN) === "true")
|
||||||
|
|
||||||
const hideNav = computed(
|
const hideNav = computed(
|
||||||
() =>
|
() =>
|
||||||
taskTab.value !== TASK_TYPE.Tutorial ||
|
taskTab.value !== TASK_TYPE.Tutorial ||
|
||||||
|
|||||||
46
src/pages/Leaderboard.vue
Normal file
46
src/pages/Leaderboard.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||||
|
<n-button secondary @click="$router.back()">返回</n-button>
|
||||||
|
<span style="font-weight: bold; font-size: 18px">排行榜</span>
|
||||||
|
<div style="width: 60px" />
|
||||||
|
</n-flex>
|
||||||
|
<n-data-table :columns="columns" :data="data" :loading="loading" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, h } from "vue"
|
||||||
|
import { Account } from "../api"
|
||||||
|
import type { DataTableColumn } from "naive-ui"
|
||||||
|
|
||||||
|
const data = ref<{ rank: number; username: string; total_score: number }[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const columns: DataTableColumn<(typeof data.value)[0]>[] = [
|
||||||
|
{ title: "排名", key: "rank", width: 70 },
|
||||||
|
{ title: "用户名", key: "username" },
|
||||||
|
{
|
||||||
|
title: "总分",
|
||||||
|
key: "total_score",
|
||||||
|
render: (row) => h("span", { style: "font-weight: bold" }, row.total_score.toFixed(2)),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
data.value = await Account.leaderboard()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
src/pages/MyScores.vue
Normal file
64
src/pages/MyScores.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
||||||
|
<n-button secondary @click="$router.back()">返回</n-button>
|
||||||
|
<span style="font-weight: bold; font-size: 18px">我的成绩</span>
|
||||||
|
<div style="width: 60px" />
|
||||||
|
</n-flex>
|
||||||
|
<n-empty v-if="!loading && !data.length" description="暂无评分记录" />
|
||||||
|
<n-data-table v-else :columns="columns" :data="data" :loading="loading" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, h } from "vue"
|
||||||
|
import { Submission } from "../api"
|
||||||
|
import type { DataTableColumn } from "naive-ui"
|
||||||
|
import { parseTime } from "../utils/helper"
|
||||||
|
|
||||||
|
type MyScore = {
|
||||||
|
task_id: number
|
||||||
|
task_display: number
|
||||||
|
task_title: string
|
||||||
|
score: number
|
||||||
|
created: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = ref<MyScore[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const columns: DataTableColumn<MyScore>[] = [
|
||||||
|
{ title: "题号", key: "task_display", width: 70 },
|
||||||
|
{ title: "标题", key: "task_title" },
|
||||||
|
{
|
||||||
|
title: "最高得分",
|
||||||
|
key: "score",
|
||||||
|
render: (row) =>
|
||||||
|
row.score > 0
|
||||||
|
? h("span", { style: { fontWeight: "bold", color: "#18a058" } }, row.score.toFixed(2))
|
||||||
|
: h("span", { style: { color: "#999" } }, "未评分"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "提交时间",
|
||||||
|
key: "created",
|
||||||
|
render: (row) => parseTime(row.created, "YYYY-MM-DD HH:mm"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
data.value = await Submission.myScores()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -393,6 +393,8 @@ watch(
|
|||||||
init()
|
init()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
onMounted(init)
|
onMounted(init)
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
submission.value = {
|
submission.value = {
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ const routes = [
|
|||||||
component: () => import("./pages/Submission.vue"),
|
component: () => import("./pages/Submission.vue"),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/leaderboard",
|
||||||
|
name: "leaderboard",
|
||||||
|
component: () => import("./pages/Leaderboard.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/my-scores",
|
||||||
|
name: "my-scores",
|
||||||
|
component: () => import("./pages/MyScores.vue"),
|
||||||
|
meta: { auth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/dashboard",
|
path: "/dashboard",
|
||||||
name: "dashboard",
|
name: "dashboard",
|
||||||
|
|||||||
Reference in New Issue
Block a user