Compare commits

..

8 Commits

Author SHA1 Message Date
a7cfa66952 fix
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-03-18 20:07:00 +08:00
0636ad6f57 fix
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-03-18 20:01:53 +08:00
e0f1cdb337 fix
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-03-18 19:57:00 +08:00
88d6ffaf53 fix
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-03-18 19:50:55 +08:00
83cd62a110 add stats
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-03-18 19:46:33 +08:00
4e95a2fad0 update
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-03-18 18:40:15 +08:00
dd52e3e1f9 fix
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-03-18 18:06:39 +08:00
3c6b616d81 fix 2026-03-18 18:06:37 +08:00
27 changed files with 1141 additions and 608 deletions

60
components.d.ts vendored
View File

@@ -1,60 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ChainModal: typeof import('./src/components/submissions/ChainModal.vue')['default']
Challenge: typeof import('./src/components/Challenge.vue')['default']
CodeModal: typeof import('./src/components/submissions/CodeModal.vue')['default']
Editor: typeof import('./src/components/Editor.vue')['default']
Editors: typeof import('./src/components/Editors.vue')['default']
ExpandedSubTable: typeof import('./src/components/submissions/ExpandedSubTable.vue')['default']
FlagCell: typeof import('./src/components/submissions/FlagCell.vue')['default']
Login: typeof import('./src/components/Login.vue')['default']
MarkdownEditor: typeof import('./src/components/dashboard/MarkdownEditor.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NCode: typeof import('naive-ui')['NCode']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDropdown: typeof import('naive-ui')['NDropdown']
NEmpty: typeof import('naive-ui')['NEmpty']
NFlex: typeof import('naive-ui')['NFlex']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid']
NInput: typeof import('naive-ui')['NInput']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NModalProvider: typeof import('naive-ui')['NModalProvider']
NPopover: typeof import('naive-ui')['NPopover']
NRate: typeof import('naive-ui')['NRate']
NSelect: typeof import('naive-ui')['NSelect']
NSpin: typeof import('naive-ui')['NSpin']
NSplit: typeof import('naive-ui')['NSplit']
NTab: typeof import('naive-ui')['NTab']
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
Preview: typeof import('./src/components/Preview.vue')['default']
PromptPanel: typeof import('./src/components/PromptPanel.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Task: typeof import('./src/components/Task.vue')['default']
TaskTitle: typeof import('./src/components/submissions/TaskTitle.vue')['default']
Toolbar: typeof import('./src/components/Toolbar.vue')['default']
Tutorial: typeof import('./src/components/Tutorial.vue')['default']
UserActions: typeof import('./src/components/dashboard/UserActions.vue')['default']
}
}

83
public/tailwindcss.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,13 @@
import axios from "axios" import axios from "axios"
import { router } from "./router" import { router } from "./router"
import type { TutorialIn, ChallengeIn, FlagType, SubmissionOut, PromptMessage } from "./utils/type" import type {
TutorialIn,
ChallengeIn,
FlagType,
SubmissionOut,
PromptMessage,
TaskStatsOut,
} from "./utils/type"
import { BASE_URL, STORAGE_KEY } from "./utils/const" import { BASE_URL, STORAGE_KEY } from "./utils/const"
const http = axios.create({ const http = axios.create({
@@ -68,11 +75,6 @@ export const Account = {
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 }[]
},
async listClasses(): Promise<string[]> { async listClasses(): Promise<string[]> {
const res = await http.get("/account/classes") const res = await http.get("/account/classes")
return res.data return res.data
@@ -226,17 +228,12 @@ export const Submission = {
return res.data as { nominated: boolean } return res.data as { nominated: boolean }
}, },
async myScores() { async getStats(taskId: number, classname?: string): Promise<TaskStatsOut> {
const res = await http.get("/submission/my-scores") const params: Record<string, string | number> = {}
return res.data as { if (classname) params.classname = classname
task_id: number const res = await http.get(`/submission/stats/${taskId}`, { params })
task_display: number return res.data as TaskStatsOut
task_title: string
score: number
created: string
}[]
}, },
} }
export const Prompt = { export const Prompt = {
@@ -249,7 +246,9 @@ export const Prompt = {
async getMessages(conversationId: string): Promise<PromptMessage[]> { async getMessages(conversationId: string): Promise<PromptMessage[]> {
return ( return (
await http.get<PromptMessage[]>(`/prompt/conversations/${conversationId}/messages/`) await http.get<PromptMessage[]>(
`/prompt/conversations/${conversationId}/messages/`,
)
).data ).data
}, },
} }

View File

@@ -19,14 +19,6 @@
</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>
@@ -39,7 +31,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, Submission } from "../api" import { Challenge } 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"
@@ -48,16 +40,6 @@ 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()
@@ -90,10 +72,7 @@ function back() {
router.push({ name: "home-challenge-list" }) router.push({ name: "home-challenge-list" })
} }
onMounted(async () => { onMounted(loadList)
await loadList()
await loadMyScores()
})
</script> </script>
<style scoped> <style scoped>
.container { .container {

View File

@@ -62,7 +62,7 @@
<n-flex align="center"> <n-flex align="center">
<span class="label">预加载</span> <span class="label">预加载</span>
<n-tag type="success">Normalize.css</n-tag> <n-tag type="success">Normalize.css</n-tag>
<n-tag type="success">jQuery</n-tag> <n-tag type="success">Tailwind CSS</n-tag>
</n-flex> </n-flex>
</n-flex> </n-flex>
</n-tab-pane> </n-tab-pane>

View File

@@ -180,7 +180,10 @@ async function submitStudent() {
studentLoading.value = true studentLoading.value = true
showStudentError.value = false showStudentError.value = false
try { try {
const data = await Account.login(selectedUsername.value, studentPassword.value) const data = await Account.login(
selectedUsername.value,
studentPassword.value,
)
user.username = data.username user.username = data.username
user.role = data.role user.role = data.role
user.loaded = true user.loaded = true

View File

@@ -5,7 +5,12 @@
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button> <n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
<n-button quaternary @click="open">全屏</n-button> <n-button quaternary @click="open">全屏</n-button>
<n-button quaternary v-if="props.clearable" @click="clear">清空</n-button> <n-button quaternary v-if="props.clearable" @click="clear">清空</n-button>
<n-button quaternary v-if="props.showCodeButton" @click="emits('showCode')">代码</n-button> <n-button
quaternary
v-if="props.showCodeButton"
@click="emits('showCode')"
>代码</n-button
>
<n-button quaternary v-if="props.submissionId" @click="copyLink"> <n-button quaternary v-if="props.submissionId" @click="copyLink">
链接 链接
</n-button> </n-button>
@@ -59,11 +64,11 @@ function getContent() {
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>${props.css}</style> <style>${props.css}</style>
<link rel="stylesheet" href="/normalize.min.css" /> <link rel="stylesheet" href="/normalize.min.css" />
<script src="/jquery.min.js"><\/script> <script src="/tailwindcss.min.js"><\/script>
</head> </head>
<body> <body>
${props.html} ${props.html}
<script type="module">${props.js}<\/script> <script>${props.js}<\/script>
</body> </body>
</html>` </html>`
} }

View File

@@ -6,7 +6,7 @@
<span>加载历史记录</span> <span>加载历史记录</span>
</div> </div>
<div v-for="(msg, i) in messages" :key="i" :class="['message', msg.role]"> <div v-for="(msg, i) in messages" :key="i" :class="['message', msg.role]">
<div class="message-role">{{ msg.role === 'user' ? '我' : 'AI' }}</div> <div class="message-role">{{ msg.role === "user" ? "我" : "AI" }}</div>
<div class="message-content" v-html="renderContent(msg)"></div> <div class="message-content" v-html="renderContent(msg)"></div>
</div> </div>
<div v-if="streaming" class="message assistant"> <div v-if="streaming" class="message assistant">
@@ -14,7 +14,11 @@
<div v-if="!streamingContent" class="typing-indicator"> <div v-if="!streamingContent" class="typing-indicator">
<span></span><span></span><span></span> <span></span><span></span><span></span>
</div> </div>
<div v-else class="message-content" v-html="renderMarkdown(streamingContent)"></div> <div
v-else
class="message-content"
v-html="renderMarkdown(streamingContent)"
></div>
<div class="streaming-hint">AI 正在思考中</div> <div class="streaming-hint">AI 正在思考中</div>
</div> </div>
</div> </div>
@@ -28,7 +32,12 @@
@keydown.enter.exact.prevent="send" @keydown.enter.exact.prevent="send"
/> />
<n-flex justify="space-between" align="center" style="margin-top: 8px"> <n-flex justify="space-between" align="center" style="margin-top: 8px">
<n-button text size="small" @click="newConversation" :disabled="streaming"> <n-button
text
size="small"
@click="newConversation"
:disabled="streaming"
>
新对话 新对话
</n-button> </n-button>
<n-button <n-button
@@ -69,13 +78,46 @@ function send() {
const renderer = new Renderer() const renderer = new Renderer()
renderer.code = function ({ lang }: { text: string; lang?: string }) { renderer.code = function ({ lang }: { text: string; lang?: string }) {
const label = lang ? lang.toUpperCase() : "CODE" const label = lang ? lang.toUpperCase() : "CODE"
const colors: Record<string, { bg: string; fg: string; dot: string; border: string; shimmer: string }> = { const colors: Record<
html: { bg: "#fff5f0", fg: "#e05020", dot: "#e05020", border: "#f0d0c0", shimmer: "#fff5f0, #ffeee5, #fff5f0" }, string,
css: { bg: "#f0f0ff", fg: "#6060d0", dot: "#6060d0", border: "#d0d0f0", shimmer: "#f0f0ff, #e8e8fa, #f0f0ff" }, { bg: string; fg: string; dot: string; border: string; shimmer: string }
js: { bg: "#fffbf0", fg: "#c0960a", dot: "#c0960a", border: "#f0e0b0", shimmer: "#fffbf0, #fff5e0, #fffbf0" }, > = {
javascript: { bg: "#fffbf0", fg: "#c0960a", dot: "#c0960a", border: "#f0e0b0", shimmer: "#fffbf0, #fff5e0, #fffbf0" }, html: {
bg: "#fff5f0",
fg: "#e05020",
dot: "#e05020",
border: "#f0d0c0",
shimmer: "#fff5f0, #ffeee5, #fff5f0",
},
css: {
bg: "#f0f0ff",
fg: "#6060d0",
dot: "#6060d0",
border: "#d0d0f0",
shimmer: "#f0f0ff, #e8e8fa, #f0f0ff",
},
js: {
bg: "#fffbf0",
fg: "#c0960a",
dot: "#c0960a",
border: "#f0e0b0",
shimmer: "#fffbf0, #fff5e0, #fffbf0",
},
javascript: {
bg: "#fffbf0",
fg: "#c0960a",
dot: "#c0960a",
border: "#f0e0b0",
shimmer: "#fffbf0, #fff5e0, #fffbf0",
},
}
const c = colors[(lang ?? "").toLowerCase()] ?? {
bg: "#f0f7ff",
fg: "#2080f0",
dot: "#2080f0",
border: "#e0eaf5",
shimmer: "#f0f7ff, #e8f4f8, #f0f7ff",
} }
const c = colors[(lang ?? "").toLowerCase()] ?? { bg: "#f0f7ff", fg: "#2080f0", dot: "#2080f0", border: "#e0eaf5", shimmer: "#f0f7ff, #e8f4f8, #f0f7ff" }
return `<div class="code-placeholder" style="background: linear-gradient(90deg, ${c.shimmer}); background-size: 200% 100%; border-color: ${c.border}"><span class="code-placeholder-dot" style="background: ${c.dot}"></span><span class="code-placeholder-label" style="color: ${c.fg}; background: ${c.fg}18">${label}</span><span class="code-placeholder-text">代码已自动应用到预览区</span></div>` return `<div class="code-placeholder" style="background: linear-gradient(90deg, ${c.shimmer}); background-size: 200% 100%; border-color: ${c.border}"><span class="code-placeholder-dot" style="background: ${c.dot}"></span><span class="code-placeholder-label" style="color: ${c.fg}; background: ${c.fg}18">${label}</span><span class="code-placeholder-text">代码已自动应用到预览区</span></div>`
} }
@@ -88,16 +130,13 @@ function renderContent(msg: { role: string; content: string }): string {
} }
// Auto-scroll to bottom on new messages // Auto-scroll to bottom on new messages
watch( watch([() => messages.value.length, streamingContent], () => {
[() => messages.value.length, streamingContent],
() => {
nextTick(() => { nextTick(() => {
if (messagesRef.value) { if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight messagesRef.value.scrollTop = messagesRef.value.scrollHeight
} }
}) })
} })
)
</script> </script>
<style scoped> <style scoped>
@@ -180,13 +219,22 @@ watch(
} }
@keyframes shimmer { @keyframes shimmer {
0% { background-position: -200% 0; } 0% {
100% { background-position: 200% 0; } background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
} }
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 0.4; } 0%,
50% { opacity: 1; } 100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
} }
.typing-indicator { .typing-indicator {
@@ -212,8 +260,16 @@ watch(
} }
@keyframes bounce { @keyframes bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 0%,
30% { transform: translateY(-6px); opacity: 1; } 60%,
100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-6px);
opacity: 1;
}
} }
.streaming-hint { .streaming-hint {
@@ -237,5 +293,4 @@ watch(
font-size: 13px; font-size: 13px;
justify-content: center; justify-content: center;
} }
</style> </style>

View File

@@ -38,6 +38,9 @@
</template> </template>
</n-flex> </n-flex>
<n-flex> <n-flex>
<n-button v-if="roleSuper" text @click="statsModal = true">
<Icon :width="16" icon="lucide:bar-chart-2"></Icon>
</n-button>
<n-button <n-button
v-if="authed" v-if="authed"
text text
@@ -45,12 +48,6 @@
> >
<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>
@@ -62,26 +59,27 @@
<Tutorial v-if="taskTab === TASK_TYPE.Tutorial" ref="tutorialRef" /> <Tutorial v-if="taskTab === TASK_TYPE.Tutorial" ref="tutorialRef" />
<Challenge v-else /> <Challenge v-else />
</div> </div>
<TaskStatsModal v-model:show="statsModal" :task-id="taskId" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { computed, onMounted, ref } from "vue" import { computed, onMounted, ref } from "vue"
import { step } from "../store/tutorial" import { step } from "../store/tutorial"
import { authed, roleSuper } from "../store/user" import { authed, roleAdmin, roleSuper } from "../store/user"
import { taskTab, challengeDisplay } from "../store/task" import { taskTab, taskId, challengeDisplay } from "../store/task"
import { useRoute, useRouter } from "vue-router" import { useRoute, useRouter } from "vue-router"
import { TASK_TYPE, STORAGE_KEY } from "../utils/const" import { TASK_TYPE } from "../utils/const"
import Challenge from "./Challenge.vue" import Challenge from "./Challenge.vue"
import Tutorial from "./Tutorial.vue" import Tutorial from "./Tutorial.vue"
import TaskStatsModal from "./TaskStatsModal.vue"
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const tutorialRef = ref<InstanceType<typeof Tutorial>>() const tutorialRef = ref<InstanceType<typeof Tutorial>>()
const statsModal = ref(false)
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 ||

View File

@@ -0,0 +1,560 @@
<template>
<n-modal
:show="show"
@update:show="$emit('update:show', $event)"
preset="card"
title="提交统计"
style="max-width: 660px"
:bordered="false"
>
<div style="max-height: 75vh; overflow-y: auto">
<!-- 初始加载 -->
<template v-if="!stats && loading">
<n-flex justify="center" style="padding: 40px">
<n-spin size="large" />
</n-flex>
</template>
<template v-else-if="stats">
<!-- 班级筛选 -->
<n-flex
align="center"
style="margin-bottom: 16px; flex-wrap: wrap; gap: 6px"
>
<span style="color: #666; font-size: 12px">班级筛选</span>
<n-button
size="small"
:type="selectedClass === null ? 'primary' : 'default'"
@click="selectClass(null)"
>全部</n-button
>
<n-button
v-for="c in stats.classes"
:key="c"
size="small"
:type="selectedClass === c ? 'primary' : 'default'"
@click="selectClass(c)"
>{{ c }}</n-button
>
</n-flex>
<n-spin :show="loading">
<!-- 关键指标 -->
<div
style="
display: grid;
grid-template-columns: repeat(5, 1fr);
border: 1px solid #eee;
border-radius: 6px;
overflow: hidden;
margin-bottom: 12px;
"
>
<div
v-for="(metric, i) in metrics"
:key="metric.label"
:style="{
padding: '12px 8px',
textAlign: 'center',
borderRight: i < metrics.length - 1 ? '1px solid #eee' : 'none',
}"
>
<div
:style="{
fontSize: '20px',
fontWeight: '700',
color: metric.color,
}"
>
{{ metric.value }}
</div>
<div style="color: #888; font-size: 11px; margin-top: 2px">
{{ metric.label }}
</div>
</div>
</div>
<!-- 未提交名单可折叠 -->
<div
style="
border: 1px solid #eee;
border-radius: 6px;
margin-bottom: 8px;
overflow: hidden;
"
>
<div
style="
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
background: #fff8f8;
"
@click="showUnsubmitted = !showUnsubmitted"
>
<n-flex align="center" :size="6">
<span
style="
width: 7px;
height: 7px;
background: #d03050;
border-radius: 50%;
display: inline-block;
"
></span>
<span style="font-weight: 600; color: #d03050; font-size: 12px"
>未提交{{ stats.unsubmitted_count }}</span
>
</n-flex>
<Icon
:icon="
showUnsubmitted ? 'lucide:chevron-down' : 'lucide:chevron-right'
"
:width="14"
style="color: #aaa"
/>
</div>
<div
v-if="showUnsubmitted"
style="
padding: 10px 14px;
background: #fff8f8;
display: flex;
flex-wrap: wrap;
gap: 5px;
"
>
<n-tag
v-for="u in stats.unsubmitted_users"
:key="u.username"
size="small"
:bordered="true"
style="border-color: #ffd0d0; background: #fff"
>{{ displayName(u.username, u.classname) }}</n-tag
>
<span
v-if="!stats.unsubmitted_users.length"
style="color: #aaa; font-size: 12px"
>暂无</span
>
</div>
</div>
<!-- 未打分名单可折叠 -->
<div
style="
border: 1px solid #eee;
border-radius: 6px;
margin-bottom: 12px;
overflow: hidden;
"
>
<div
style="
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
background: #fffaf5;
"
@click="showUnrated = !showUnrated"
>
<n-flex align="center" :size="6">
<span
style="
width: 7px;
height: 7px;
background: #e07800;
border-radius: 50%;
display: inline-block;
"
></span>
<span style="font-weight: 600; color: #e07800; font-size: 12px"
>未打分{{ stats.unrated_count }}</span
>
<span style="font-size: 11px; color: #aaa"
> 还没给任何提交打过分</span
>
</n-flex>
<Icon
:icon="
showUnrated ? 'lucide:chevron-down' : 'lucide:chevron-right'
"
:width="14"
style="color: #aaa"
/>
</div>
<div
v-if="showUnrated"
style="
padding: 10px 14px;
background: #fffaf5;
display: flex;
flex-wrap: wrap;
gap: 5px;
"
>
<n-tag
v-for="u in stats.unrated_users"
:key="u.username"
size="small"
:bordered="true"
style="border-color: #ffd0a0; background: #fff"
>{{ displayName(u.username, u.classname) }}</n-tag
>
<span
v-if="!stats.unrated_users.length"
style="color: #aaa; font-size: 12px"
>暂无</span
>
</div>
</div>
<!-- 提交次数分布 -->
<div style="margin-bottom: 12px">
<div
style="
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
color: #333;
"
>
提交次数分布
<span style="font-size: 11px; color: #aaa; font-weight: 400"
>已提交的 {{ stats.submitted_count }} </span
>
</div>
<div style="display: flex; gap: 8px">
<div
v-for="bucket in countBuckets"
:key="bucket.label"
:style="{
flex: 1,
border: `1px solid ${bucket.borderColor}`,
borderRadius: '6px',
padding: '10px 8px',
textAlign: 'center',
background: bucket.bg,
}"
>
<div
:style="{
fontSize: '20px',
fontWeight: '700',
color: bucket.color,
}"
>
{{ bucket.value }}
</div>
<div style="color: #aaa; font-size: 11px; margin-top: 2px">
{{ bucket.label }}
</div>
<div
style="
margin-top: 8px;
background: #e8e8e8;
border-radius: 3px;
height: 4px;
"
>
<div
:style="{
background: bucket.color,
height: '4px',
borderRadius: '3px',
width: bucketPct(bucket.value),
}"
></div>
</div>
<div style="color: #bbb; font-size: 10px; margin-top: 3px">
{{ bucketPct(bucket.value) }}
</div>
</div>
</div>
</div>
<!-- 人气提交 Top 5 -->
<div style="margin-bottom: 12px">
<div
style="
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
color: #333;
"
>
人气提交 Top 5
<span style="font-size: 11px; color: #aaa; font-weight: 400"
>按打分人数</span
>
</div>
<div style="display: flex; flex-direction: column; gap: 5px">
<div
v-for="(sub, i) in stats.top_submissions"
:key="sub.submission_id"
:style="{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '6px 10px',
background: rankBg(i),
borderRadius: '6px',
cursor: 'pointer',
}"
@click="viewSubmission(sub.submission_id)"
>
<div
:style="{
width: '20px',
height: '20px',
background: rankColor(i),
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontWeight: '700',
fontSize: '11px',
flexShrink: 0,
}"
>
{{ i + 1 }}
</div>
<div style="flex: 1">
<div style="font-weight: 500; font-size: 13px">
{{ displayName(sub.username, sub.classname) }}
</div>
<div style="color: #aaa; font-size: 11px">
{{ sub.score.toFixed(1) }} · {{ sub.rating_count }} 人打分
</div>
</div>
<div style="color: #2080f0; font-size: 12px">查看 </div>
</div>
<span
v-if="!stats.top_submissions.length"
style="color: #aaa; font-size: 12px"
>暂无打分记录</span
>
</div>
</div>
<!-- 标记统计 -->
<div>
<div
style="
font-weight: 600;
font-size: 13px;
margin-bottom: 8px;
color: #333;
"
>
标记统计
</div>
<n-flex :size="8" style="flex-wrap: wrap">
<div
v-for="flag in flagBadges"
:key="flag.label"
:style="{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '4px 10px',
background: flag.bg,
borderRadius: '4px',
border: `1px solid ${flag.border}`,
}"
>
<span
:style="{
width: '7px',
height: '7px',
background: flag.color,
borderRadius: '50%',
display: 'inline-block',
}"
></span>
<span :style="{ color: flag.color, fontSize: '12px' }">{{
flag.label
}}</span>
<span style="font-weight: 700; font-size: 13px; color: #333">{{
flag.value
}}</span>
</div>
</n-flex>
</div>
</n-spin>
</template>
</div>
</n-modal>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from "vue"
import { Icon } from "@iconify/vue"
import { useRouter } from "vue-router"
import { Submission } from "../api"
import type { TaskStatsOut } from "../utils/type"
const props = defineProps<{ taskId: number; show: boolean }>()
const emit = defineEmits<{ (e: "update:show", v: boolean): void }>()
const router = useRouter()
const stats = ref<TaskStatsOut | null>(null)
const loading = ref(false)
const selectedClass = ref<string | null>(null)
const showUnsubmitted = ref(false)
const showUnrated = ref(false)
async function load(classname?: string) {
loading.value = true
try {
stats.value = await Submission.getStats(props.taskId, classname)
} finally {
loading.value = false
}
}
function displayName(username: string, classname: string) {
const prefix = "web" + classname
return username.startsWith(prefix) ? username.slice(prefix.length) : username
}
function selectClass(c: string | null) {
selectedClass.value = c
load(c ?? undefined)
}
function viewSubmission(id: string) {
const { href } = router.resolve({ name: "submission", params: { id } })
window.open(href, "_blank")
}
function rankColor(i: number) {
return (["#f0a020", "#909090", "#cd7f32", "#8899aa", "#7a8fa0"] as const)[i] ?? "#aaa"
}
function rankBg(i: number) {
return (["#fffbef", "#f8f8f8", "#fdf5ee", "#f2f5f8", "#eef2f5"] as const)[i] ?? "#f8f8f8"
}
function bucketPct(value: number) {
const total = stats.value?.submitted_count ?? 0
if (!total) return "0%"
return Math.round((value / total) * 100) + "%"
}
const metrics = computed(() => {
if (!stats.value) return []
return [
{ label: "已提交", value: stats.value.submitted_count, color: "#18a058" },
{ label: "未提交", value: stats.value.unsubmitted_count, color: "#d03050" },
{
label: "平均分",
value: stats.value.average_score?.toFixed(1) ?? "—",
color: "#2080f0",
},
{ label: "未打分", value: stats.value.unrated_count, color: "#d03050" },
{ label: "参与排名", value: stats.value.nominated_count, color: "#f0a020" },
]
})
const countBuckets = computed(() => {
if (!stats.value) return []
const d = stats.value.submission_count_distribution
return [
{
label: "4 次+",
value: d.count_4_plus,
color: "#f0a020",
bg: "#fffbf0",
borderColor: "#ffe0a0",
},
{
label: "3 次",
value: d.count_3,
color: "#18a058",
bg: "#f0fff4",
borderColor: "#c8e8d0",
},
{
label: "2 次",
value: d.count_2,
color: "#2080f0",
bg: "#f0f7ff",
borderColor: "#d0e8ff",
},
{
label: "仅 1 次",
value: d.count_1,
color: "#888",
bg: "#fafafa",
borderColor: "#e0e0e0",
},
]
})
const scoreBars = computed(() => {
if (!stats.value) return []
const d = stats.value.score_distribution
const vals = [d.range_1_2, d.range_2_3, d.range_3_4, d.range_4_5, d.range_5]
const max = Math.max(...vals, 1)
const colors = ["#d03050", "#f0a020", "#2080f0", "#18a058", "#18a058"]
const labels = ["★", "★★", "★★★", "★★★★", "★★★★★"]
return vals.map((v, i) => ({
value: v,
label: labels[i],
color: colors[i],
height: Math.max(Math.round((v / max) * 68), 3) + "px",
}))
})
const flagBadges = computed(() => {
if (!stats.value) return []
const f = stats.value.flag_stats
return [
{
label: "值得展示",
value: f.red,
color: "#d03050",
bg: "#fff0f0",
border: "#ffd0d0",
},
{
label: "需要讲解",
value: f.blue,
color: "#2080f0",
bg: "#f0f7ff",
border: "#c8deff",
},
{
label: "优秀作品",
value: f.green,
color: "#18a058",
bg: "#f0fff4",
border: "#b8e8c8",
},
{
label: "需要改进",
value: f.yellow,
color: "#f0a020",
bg: "#fffbf0",
border: "#ffe8a0",
},
]
})
// Load when modal opens
watch(
() => props.show,
(val) => {
if (val && props.taskId) {
selectedClass.value = null
load()
}
},
)
</script>

View File

@@ -85,7 +85,9 @@ function flash(btn: HTMLButtonElement, done: string, original: string) {
function setupCodeActions() { function setupCodeActions() {
$content.value?.addEventListener("click", (e: MouseEvent) => { $content.value?.addEventListener("click", (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action]") const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(
"[data-action]",
)
if (!btn) return if (!btn) return
const wrapper = btn.closest<HTMLElement>("[data-lang]")! const wrapper = btn.closest<HTMLElement>("[data-lang]")!
const lang = wrapper.dataset.lang ?? "html" const lang = wrapper.dataset.lang ?? "html"

View File

@@ -7,10 +7,18 @@
@update:show="$emit('update:show', $event)" @update:show="$emit('update:show', $event)"
> >
<n-spin :show="loading"> <n-spin :show="loading">
<n-empty v-if="!loading && rounds.length === 0" description="暂无对话记录" /> <n-empty
v-if="!loading && rounds.length === 0"
description="暂无对话记录"
/>
<div <div
v-else v-else
style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; height: 75vh" style="
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
height: 75vh;
"
> >
<div <div
style=" style="
@@ -25,26 +33,46 @@
<div <div
v-for="(round, index) in rounds" v-for="(round, index) in rounds"
:key="index" :key="index"
style="display: flex; gap: 10px; align-items: flex-start; cursor: pointer" style="
display: flex;
gap: 10px;
align-items: flex-start;
cursor: pointer;
"
@click="selectedRound = index" @click="selectedRound = index"
> >
<div <div
:style="{ :style="{
flexShrink: 0, width: '22px', height: '22px', borderRadius: '50%', flexShrink: 0,
width: '22px',
height: '22px',
borderRadius: '50%',
background: selectedRound === index ? '#2080f0' : '#c2d5fb', background: selectedRound === index ? '#2080f0' : '#c2d5fb',
color: '#fff', fontSize: '12px', fontWeight: 'bold', color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12px',
marginTop: '2px', transition: 'background 0.2s', fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: '2px',
transition: 'background 0.2s',
}" }"
> >
{{ index + 1 }} {{ index + 1 }}
</div> </div>
<div <div
:style="{ :style="{
flex: 1, padding: '10px 14px', borderRadius: '8px', flex: 1,
padding: '10px 14px',
borderRadius: '8px',
background: selectedRound === index ? '#e8f0fe' : '#f5f5f5', background: selectedRound === index ? '#e8f0fe' : '#f5f5f5',
border: selectedRound === index ? '1px solid #2080f0' : '1px solid #e0e0e0', border:
fontSize: '13px', lineHeight: '1.6', transition: 'all 0.2s', selectedRound === index
? '1px solid #2080f0'
: '1px solid #e0e0e0',
fontSize: '13px',
lineHeight: '1.6',
transition: 'all 0.2s',
}" }"
> >
{{ round.question }} {{ round.question }}
@@ -60,7 +88,12 @@
:srcdoc="selectedPageHtml" :srcdoc="selectedPageHtml"
:key="selectedRound" :key="selectedRound"
sandbox="allow-scripts" sandbox="allow-scripts"
style="flex: 1; border: 1px solid #e0e0e0; border-radius: 6px; background: #fff" style="
flex: 1;
border: 1px solid #e0e0e0;
border-radius: 6px;
background: #fff;
"
/> />
<n-empty v-else description="该轮无网页代码" style="margin: auto" /> <n-empty v-else description="该轮无网页代码" style="margin: auto" />
</div> </div>
@@ -86,10 +119,17 @@ const messages = ref<PromptMessage[]>([])
const selectedRound = ref(0) const selectedRound = ref(0)
const rounds = computed(() => { const rounds = computed(() => {
const result: { question: string; html: string | null; css: string | null; js: string | null }[] = [] const result: {
question: string
html: string | null
css: string | null
js: string | null
}[] = []
for (const [i, msg] of messages.value.entries()) { for (const [i, msg] of messages.value.entries()) {
if (msg.role !== "user") continue if (msg.role !== "user") continue
let html: string | null = null, css: string | null = null, js: string | null = null let html: string | null = null,
css: string | null = null,
js: string | null = null
for (const reply of messages.value.slice(i + 1)) { for (const reply of messages.value.slice(i + 1)) {
if (reply.role === "user") break if (reply.role === "user") break
if (reply.role === "assistant" && reply.code_html) { if (reply.role === "assistant" && reply.code_html) {

View File

@@ -1,9 +1,16 @@
<template> <template>
<n-modal preset="card" :show="show" style="max-width: 60%" @update:show="$emit('update:show', $event)"> <n-modal
preset="card"
:show="show"
style="max-width: 60%"
@update:show="$emit('update:show', $event)"
>
<template #header> <template #header>
<n-flex align="center"> <n-flex align="center">
<span>前端代码</span> <span>前端代码</span>
<n-button tertiary @click="$emit('copy-to-editor')">复制到编辑框</n-button> <n-button tertiary @click="$emit('copy-to-editor')"
>复制到编辑框</n-button
>
</n-flex> </n-flex>
</template> </template>
<n-tabs animated type="segment"> <n-tabs animated type="segment">

View File

@@ -14,7 +14,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, h } from "vue" import { computed, h } from "vue"
import { NButton, NDataTable, NPopconfirm, NSpin, type DataTableColumn } from "naive-ui" import {
NButton,
NDataTable,
NPopconfirm,
NSpin,
type DataTableColumn,
} from "naive-ui"
import type { SubmissionOut } from "../../utils/type" import type { SubmissionOut } from "../../utils/type"
import { TASK_TYPE } from "../../utils/const" import { TASK_TYPE } from "../../utils/const"
import { parseTime } from "../../utils/helper" import { parseTime } from "../../utils/helper"
@@ -31,6 +37,7 @@ const emit = defineEmits<{
select: [id: string] select: [id: string]
delete: [row: SubmissionOut, parentId: string] delete: [row: SubmissionOut, parentId: string]
"show-chain": [conversationId: string] "show-chain": [conversationId: string]
nominate: [row: SubmissionOut]
}>() }>()
const isChallenge = computed(() => props.row.task_type === TASK_TYPE.Challenge) const isChallenge = computed(() => props.row.task_type === TASK_TYPE.Challenge)
@@ -60,14 +67,43 @@ const subColumns = computed((): DataTableColumn<SubmissionOut>[] => [
render: (r) => { render: (r) => {
const myScore = r.my_score > 0 ? String(r.my_score) : "-" const myScore = r.my_score > 0 ? String(r.my_score) : "-"
const avgScore = r.score > 0 ? r.score.toFixed(2) : "-" const avgScore = r.score > 0 ? r.score.toFixed(2) : "-"
return h("div", { style: { display: "flex", gap: "6px", alignItems: "baseline" } }, [ return h(
"div",
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
[
h("span", avgScore), h("span", avgScore),
h("span", { style: { fontSize: "11px", color: "#999" } }, myScore), h("span", { style: { fontSize: "11px", color: "#999" } }, myScore),
]) ],
)
},
},
{
title: "排名",
key: "nominated",
width: 60,
render: (r: SubmissionOut) => {
if (r.username !== user.username) {
return r.nominated
? h("span", { style: { color: "#f0a020" } }, "🏅")
: null
}
return h(
NButton,
{
text: true,
title: r.nominated ? "已参与排名点击可重新提名" : "参与排名",
onClick: (e: Event) => {
e.stopPropagation()
emit("nominate", r)
},
},
() => (r.nominated ? "🏅" : ""),
)
}, },
}, },
...(isChallenge.value ...(isChallenge.value
? [{ ? [
{
title: "提示词", title: "提示词",
key: "conversation_id", key: "conversation_id",
width: 70, width: 70,
@@ -75,14 +111,23 @@ const subColumns = computed((): DataTableColumn<SubmissionOut>[] => [
if (!r.conversation_id) return "-" if (!r.conversation_id) return "-"
return h( return h(
NButton, NButton,
{ text: true, type: "primary", onClick: (e: Event) => { e.stopPropagation(); emit("show-chain", r.conversation_id!) } }, {
text: true,
type: "primary",
onClick: (e: Event) => {
e.stopPropagation()
emit("show-chain", r.conversation_id!)
},
},
() => "查看", () => "查看",
) )
}, },
} as DataTableColumn<SubmissionOut>] } as DataTableColumn<SubmissionOut>,
]
: []), : []),
...(!isChallenge.value ...(!isChallenge.value
? [{ ? [
{
title: "操作", title: "操作",
key: "actions", key: "actions",
width: 60, width: 60,
@@ -92,16 +137,23 @@ const subColumns = computed((): DataTableColumn<SubmissionOut>[] => [
NPopconfirm, NPopconfirm,
{ onPositiveClick: () => emit("delete", r, props.row.id) }, { onPositiveClick: () => emit("delete", r, props.row.id) },
{ {
trigger: () => h( trigger: () =>
h(
NButton, NButton,
{ text: true, type: "error", size: "small", onClick: (e: Event) => e.stopPropagation() }, {
text: true,
type: "error",
size: "small",
onClick: (e: Event) => e.stopPropagation(),
},
() => "删除", () => "删除",
), ),
default: () => "确定删除这次提交", default: () => "确定删除这次提交",
}, },
) )
}, },
} as DataTableColumn<SubmissionOut>] } as DataTableColumn<SubmissionOut>,
]
: []), : []),
]) ])
</script> </script>

View File

@@ -13,14 +13,23 @@
<span style="display: flex; align-items: center; gap: 6px"> <span style="display: flex; align-items: center; gap: 6px">
<span <span
:style="{ :style="{
display: 'inline-block', width: '10px', height: '10px', display: 'inline-block',
borderRadius: '50%', backgroundColor: opt.color, width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: opt.color,
}" }"
/> />
{{ opt.label }} {{ opt.label }}
</span> </span>
</n-button> </n-button>
<n-button v-if="flag" text block type="error" @click="$emit('update:flag', null)"> <n-button
v-if="flag"
text
block
type="error"
@click="$emit('update:flag', null)"
>
清除 清除
</n-button> </n-button>
</n-space> </n-space>
@@ -32,7 +41,11 @@
import { computed } from "vue" import { computed } from "vue"
import type { FlagType } from "../../utils/type" import type { FlagType } from "../../utils/type"
const FLAG_OPTIONS: { value: NonNullable<FlagType>; color: string; label: string }[] = [ const FLAG_OPTIONS: {
value: NonNullable<FlagType>
color: string
label: string
}[] = [
{ value: "red", color: "#e03030", label: "值得展示" }, { value: "red", color: "#e03030", label: "值得展示" },
{ value: "blue", color: "#2080f0", label: "需要讲解" }, { value: "blue", color: "#2080f0", label: "需要讲解" },
{ value: "green", color: "#18a058", label: "优秀作品" }, { value: "green", color: "#18a058", label: "优秀作品" },

View File

@@ -9,7 +9,11 @@
</n-button> </n-button>
</template> </template>
<n-tab-pane name="desc" tab="挑战描述" display-directive="show"> <n-tab-pane name="desc" tab="挑战描述" display-directive="show">
<div class="markdown-body" style="padding: 12px; overflow-y: auto; height: 100%" v-html="challengeContent" /> <div
class="markdown-body"
style="padding: 12px; overflow-y: auto; height: 100%"
v-html="challengeContent"
/>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="chat" tab="AI 对话" display-directive="show"> <n-tab-pane name="chat" tab="AI 对话" display-directive="show">
<PromptPanel /> <PromptPanel />
@@ -19,11 +23,24 @@
</template> </template>
<template #2> <template #2>
<div class="right-panel"> <div class="right-panel">
<Preview :html="html" :css="css" :js="js" show-code-button clearable @showCode="showCode = true" @clear="clearAll" /> <Preview
:html="html"
:css="css"
:js="js"
show-code-button
clearable
@showCode="showCode = true"
@clear="clearAll"
/>
</div> </div>
</template> </template>
</n-split> </n-split>
<n-modal v-model:show="showCode" preset="card" title="代码" style="width: 700px"> <n-modal
v-model:show="showCode"
preset="card"
title="代码"
style="width: 700px"
>
<n-tabs type="line"> <n-tabs type="line">
<n-tab-pane name="html" tab="HTML"> <n-tab-pane name="html" tab="HTML">
<n-code :code="html" language="html" /> <n-code :code="html" language="html" />
@@ -49,7 +66,14 @@ import Preview from "../components/Preview.vue"
import { Challenge, Submission } from "../api" import { Challenge, Submission } from "../api"
import { html, css, js } from "../store/editors" import { html, css, js } from "../store/editors"
import { taskId } from "../store/task" import { taskId } from "../store/task"
import { connectPrompt, disconnectPrompt, conversationId, streaming, setOnCodeComplete, loadHistory } from "../store/prompt" import {
connectPrompt,
disconnectPrompt,
conversationId,
streaming,
setOnCodeComplete,
loadHistory,
} from "../store/prompt"
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()

View File

@@ -1,46 +0,0 @@
<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>

View File

@@ -1,64 +0,0 @@
<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>

View File

@@ -1,222 +0,0 @@
<template>
<n-split class="container" direction="horizontal" :default-size="0.333" :min="0.2" :max="0.8">
<template #1>
<n-flex vertical style="height: 100%; padding-right: 10px">
<n-flex justify="space-between" align="center">
<n-button secondary @click="$router.back()">返回</n-button>
<span style="font-weight: bold; font-size: 16px">排名榜</span>
<div style="width: 60px" />
</n-flex>
<n-tabs v-model:value="activeZone" type="line" animated @update:value="onZoneChange">
<n-tab v-for="zone in ZONES" :key="zone.key" :name="zone.key">
<n-flex align="center" :style="{ gap: '4px' }">
<span>{{ zone.label }}</span>
<n-badge
v-if="counts[zone.key] !== undefined"
:value="counts[zone.key]"
:max="999"
:show-zero="true"
style="margin-left: 4px"
/>
</n-flex>
</n-tab>
</n-tabs>
<n-empty
v-if="!loading && data.length === 0"
description="该区暂无提交"
style="margin: auto"
/>
<n-data-table
v-else
striped
:columns="columns"
:data="data"
:loading="loading"
:row-props="rowProps"
:row-class-name="rowClassName"
/>
<n-pagination
v-model:page="page"
:page-size="PAGE_SIZE"
:item-count="counts[activeZone] ?? 0"
simple
style="align-self: flex-end"
/>
</n-flex>
</template>
<template #2>
<div style="height: 100%; padding-left: 10px">
<Preview
v-if="selectedSubmission.id"
:html="selectedSubmission.html"
:css="selectedSubmission.css"
:js="selectedSubmission.js"
:submission-id="selectedSubmission.id"
@after-score="afterScore"
/>
</div>
</template>
</n-split>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch, h } from "vue"
import type { DataTableColumn } from "naive-ui"
import { Submission } from "../api"
import type { SubmissionOut } from "../utils/type"
import { parseTime } from "../utils/helper"
import Preview from "../components/Preview.vue"
import { submission as submissionStore } from "../store/submission"
const PAGE_SIZE = 10
interface Zone {
key: string
label: string
params: Record<string, unknown>
}
const ZONES: Zone[] = [
{
key: "top",
label: "🏆 精华",
params: { score_min: 4.5, ordering: "-score" },
},
{
key: "good",
label: "⭐ 优秀",
params: { score_min: 3.5, score_max_exclusive: 4.5, ordering: "-score" },
},
{
key: "normal",
label: "📝 普通",
params: { score_min: 0.001, score_max_exclusive: 3.5, ordering: "-score" },
},
{
key: "unrated",
label: "⏳ 待评",
params: { score_lt_threshold: 0.001, ordering: "-created" },
},
]
const activeZone = ref("top")
const page = ref(1)
const data = ref<SubmissionOut[]>([])
const loading = ref(false)
const counts = reactive<Record<string, number>>({})
const selectedSubmission = submissionStore
const columns: DataTableColumn<SubmissionOut>[] = [
{
title: "#",
key: "rank",
width: 45,
render: (_, index) => (page.value - 1) * PAGE_SIZE + index + 1,
},
{
title: "得分",
key: "score",
width: 65,
render: (row) =>
row.score > 0
? h("span", { style: { fontWeight: "bold" } }, row.score.toFixed(2))
: h("span", { style: { color: "#999" } }, "—"),
},
{ title: "提交者", key: "username", width: 80, render: (row) => row.username },
{ title: "任务", key: "task_title", render: (row) => row.task_title },
{
title: "时间",
key: "created",
width: 110,
render: (row) => parseTime(row.created, "YYYY-MM-DD HH:mm:ss"),
},
]
function rowProps(row: SubmissionOut) {
return {
style: { cursor: "pointer" },
onClick: () => loadSubmission(row.id),
}
}
function rowClassName(row: SubmissionOut) {
return submissionStore.value.id === row.id ? "row-active" : ""
}
async function loadSubmission(id: string) {
submissionStore.value = await Submission.get(id)
}
function afterScore() {
data.value = data.value.map((d) => {
if (d.id === submissionStore.value.id) {
d.my_score = submissionStore.value.my_score
}
return d
})
}
function currentZone(): Zone {
return ZONES.find((z) => z.key === activeZone.value)!
}
async function fetchPage() {
loading.value = true
try {
const zone = currentZone()
const res = await Submission.list({
page: page.value,
task_type: "challenge",
nominated: true,
...zone.params,
} as Parameters<typeof Submission.list>[0])
data.value = res.items
counts[zone.key] = res.count
} finally {
loading.value = false
}
}
async function fetchAllCounts() {
await Promise.all(
ZONES.map(async (zone) => {
const res = await Submission.list({
page: 1,
task_type: "challenge",
nominated: true,
...zone.params,
} as Parameters<typeof Submission.list>[0])
counts[zone.key] = res.count
}),
)
}
function onZoneChange() {
page.value = 1
fetchPage()
}
watch(page, fetchPage)
onMounted(async () => {
await fetchAllCounts()
await fetchPage()
})
</script>
<style scoped>
.container {
padding: 10px;
box-sizing: border-box;
height: calc(100% - 43px);
width: 100%;
}
:deep(.row-active td) {
background-color: rgba(24, 160, 80, 0.1) !important;
}
</style>

View File

@@ -25,11 +25,11 @@ async function init() {
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>${submission.css}</style> <style>${submission.css}</style>
<link rel="stylesheet" href="/normalize.min.css" /> <link rel="stylesheet" href="/normalize.min.css" />
<script src="/jquery.min.js"><\/script> <script src="/tailwindcss.min.js"><\/script>
</head> </head>
<body> <body>
${submission.html} ${submission.html}
<script type="module">${submission.js}<\/script> <script>${submission.js}<\/script>
</body> </body>
</html>`) </html>`)
doc.close() doc.close()
@@ -43,6 +43,8 @@ onMounted(init)
</template> </template>
<style scoped> <style scoped>
.iframe { .iframe {
position: fixed;
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
border: none; border: none;

View File

@@ -7,7 +7,10 @@
:max="0.8" :max="0.8"
> >
<template #1> <template #1>
<n-flex vertical style="height: 100%; padding-right: 10px; overflow: hidden"> <n-flex
vertical
style="height: 100%; padding-right: 10px; overflow: hidden"
>
<n-flex justify="space-between" style="flex-shrink: 0"> <n-flex justify="space-between" style="flex-shrink: 0">
<n-button secondary @click="() => goHome($router, taskTab, step)"> <n-button secondary @click="() => goHome($router, taskTab, step)">
返回首页 返回首页
@@ -21,14 +24,23 @@
:options="flagFilterOptions" :options="flagFilterOptions"
@update:value="handleFlagSelect" @update:value="handleFlagSelect"
/> />
<n-input style="width: 120px" v-model:value="query.username" clearable /> <n-input
style="width: 120px"
v-model:value="query.username"
clearable
/>
<n-pagination <n-pagination
v-model:page="query.page" v-model:page="query.page"
:page-size="10" :page-size="10"
:item-count="count" :item-count="count"
simple simple
/> />
<n-button secondary style="padding: 0 10px" title="刷新" @click="init"> <n-button
secondary
style="padding: 0 10px"
title="刷新"
@click="init"
>
<Icon :width="16" icon="lucide:refresh-cw" /> <Icon :width="16" icon="lucide:refresh-cw" />
</n-button> </n-button>
</n-flex> </n-flex>
@@ -109,7 +121,9 @@ const data = ref<SubmissionOut[]>([])
const count = ref(0) const count = ref(0)
const query = reactive({ const query = reactive({
page: Number(route.params.page), page: Number(route.params.page),
username: (Array.isArray(route.query.username) ? "" : (route.query.username ?? "")) as string, username: (Array.isArray(route.query.username)
? ""
: (route.query.username ?? "")) as string,
flag: null as string | null, flag: null as string | null,
}) })
@@ -145,7 +159,10 @@ const flagFilterOptions = computed(() => {
}) })
function handleFlagSelect(value: string | null) { function handleFlagSelect(value: string | null) {
if (value === "_clear_all") { clearAllFlags(); return } if (value === "_clear_all") {
clearAllFlags()
return
}
query.flag = value query.flag = value
} }
@@ -178,6 +195,7 @@ const columns: DataTableColumn<SubmissionOut>[] = [
onSelect: (id) => getSubmissionByID(id), onSelect: (id) => getSubmissionByID(id),
onDelete: (r, parentId) => handleDelete(r, parentId), onDelete: (r, parentId) => handleDelete(r, parentId),
"onShow-chain": (id) => showChain(id), "onShow-chain": (id) => showChain(id),
onNominate: (r) => handleNominateChild(r, row.id),
}), }),
}, },
{ {
@@ -191,25 +209,6 @@ const columns: DataTableColumn<SubmissionOut>[] = [
"onUpdate:flag": (flag: FlagType) => updateFlag(row, flag), "onUpdate:flag": (flag: FlagType) => updateFlag(row, flag),
}), }),
}, },
{
title: "排名",
key: "nominated",
width: 60,
render: (row) => {
if (row.username !== user.username) {
return row.nominated ? h("span", { style: { color: "#f0a020" } }, "🏅") : null
}
return h(
NButton,
{
text: true,
title: row.nominated ? "已参与排名点击可重新提名" : "参与排名",
onClick: (e: Event) => { e.stopPropagation(); handleNominate(row) },
},
() => (row.nominated ? "🏅" : ""),
)
},
},
{ {
title: "时间", title: "时间",
key: "created", key: "created",
@@ -234,10 +233,14 @@ const columns: DataTableColumn<SubmissionOut>[] = [
render: (row) => { render: (row) => {
const myScore = row.my_score > 0 ? String(row.my_score) : "-" const myScore = row.my_score > 0 ? String(row.my_score) : "-"
const avgScore = row.score > 0 ? row.score.toFixed(2) : "-" const avgScore = row.score > 0 ? row.score.toFixed(2) : "-"
return h("div", { style: { display: "flex", gap: "6px", alignItems: "baseline" } }, [ return h(
"div",
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
[
h("span", avgScore), h("span", avgScore),
h("span", { style: { fontSize: "11px", color: "#999" } }, myScore), h("span", { style: { fontSize: "11px", color: "#999" } }, myScore),
]) ],
)
}, },
}, },
{ {
@@ -269,7 +272,11 @@ async function handleExpand(keys: (string | number)[]) {
async function handleDelete(row: SubmissionOut, parentId: string) { async function handleDelete(row: SubmissionOut, parentId: string) {
await Submission.delete(row.id) await Submission.delete(row.id)
const items = expandedData.get(parentId) const items = expandedData.get(parentId)
if (items) expandedData.set(parentId, items.filter((d) => d.id !== row.id)) if (items)
expandedData.set(
parentId,
items.filter((d) => d.id !== row.id),
)
if (submission.value.id === row.id) submission.value.id = "" if (submission.value.id === row.id) submission.value.id = ""
const res = await Submission.list(query) const res = await Submission.list(query)
data.value = res.items data.value = res.items
@@ -277,7 +284,17 @@ async function handleDelete(row: SubmissionOut, parentId: string) {
} }
function rowProps(row: SubmissionOut) { function rowProps(row: SubmissionOut) {
return { style: { cursor: "pointer" }, onClick: () => getSubmissionByID(row.id) } return {
style: { cursor: "pointer" },
onClick: () => {
getSubmissionByID(row.id)
handleExpand(
expandedKeys.value.includes(row.id)
? expandedKeys.value.filter((k) => k !== row.id)
: [...expandedKeys.value, row.id],
)
},
}
} }
function rowClassName(row: SubmissionOut) { function rowClassName(row: SubmissionOut) {
@@ -296,8 +313,15 @@ async function getSubmissionByID(id: string) {
submission.value = await Submission.get(id) submission.value = await Submission.get(id)
} }
async function handleNominate(row: SubmissionOut) { async function handleNominateChild(row: SubmissionOut, parentId: string) {
await Submission.nominate(row.id) await Submission.nominate(row.id)
const items = expandedData.get(parentId)
if (items) {
expandedData.set(
parentId,
items.map((d) => ({ ...d, nominated: d.id === row.id })),
)
}
data.value = data.value.map((d) => { data.value = data.value.map((d) => {
if (d.username === user.username && d.task_id === row.task_id) { if (d.username === user.username && d.task_id === row.task_id) {
d.nominated = d.id === row.id d.nominated = d.id === row.id
@@ -320,18 +344,46 @@ function copyToEditor() {
goHome(router, submission.value.task_type, submission.value.task_display) goHome(router, submission.value.task_type, submission.value.task_display)
} }
watch(() => query.page, (v) => { init(); router.push({ params: { page: v } }) }) watch(
watchDebounced(() => query.username, () => { query.page = 1; init() }, { debounce: 500, maxWait: 1000 }) () => query.page,
watch(() => query.flag, () => { query.page = 1; init() }) (v) => {
init()
router.push({ params: { page: v } })
},
)
watchDebounced(
() => query.username,
() => {
query.page = 1
init()
},
{ debounce: 500, maxWait: 1000 },
)
watch(
() => query.flag,
() => {
query.page = 1
init()
},
)
onMounted(init) onMounted(init)
onUnmounted(() => { onUnmounted(() => {
submission.value = { submission.value = {
id: "", userid: 0, username: "", id: "",
task_id: 0, task_display: 0, task_title: "", userid: 0,
username: "",
task_id: 0,
task_display: 0,
task_title: "",
task_type: TASK_TYPE.Tutorial, task_type: TASK_TYPE.Tutorial,
score: 0, my_score: 0, html: "", css: "", js: "", score: 0,
created: new Date(), modified: new Date(), my_score: 0,
html: "",
css: "",
js: "",
created: new Date(),
modified: new Date(),
} }
}) })
</script> </script>

View File

@@ -81,7 +81,6 @@ const message = useMessage()
const confirm = useDialog() const confirm = useDialog()
const list = ref<TutorialSlim[]>([]) const list = ref<TutorialSlim[]>([])
const content = ref("")
const tutorial = reactive({ const tutorial = reactive({
display: 0, display: 0,
title: "", title: "",
@@ -102,7 +101,6 @@ function createNew() {
tutorial.title = "" tutorial.title = ""
tutorial.content = "" tutorial.content = ""
tutorial.is_public = false tutorial.is_public = false
content.value = ""
} }
async function submit() { async function submit() {
@@ -114,7 +112,6 @@ async function submit() {
tutorial.content = "" tutorial.content = ""
tutorial.is_public = false tutorial.is_public = false
await getContent() await getContent()
content.value = ""
} catch (error: any) { } catch (error: any) {
message.error(error.response.data.detail) message.error(error.response.data.detail)
} }

View File

@@ -25,23 +25,6 @@ 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: "/ranking",
name: "ranking",
component: () => import("./pages/Ranking.vue"),
meta: { auth: true },
},
{
path: "/my-scores",
name: "my-scores",
component: () => import("./pages/MyScores.vue"),
meta: { auth: true },
},
{ {
path: "/dashboard", path: "/dashboard",
name: "dashboard", name: "dashboard",

View File

@@ -19,7 +19,13 @@ export const streaming = ref(false)
export const historyLoading = ref(false) export const historyLoading = ref(false)
let _historyLoadId = 0 let _historyLoadId = 0
export const streamingContent = ref("") export const streamingContent = ref("")
let _onCodeComplete: ((code: { html: string | null; css: string | null; js: string | null }) => void) | null = null let _onCodeComplete:
| ((code: {
html: string | null
css: string | null
js: string | null
}) => void)
| null = null
export function setOnCodeComplete(fn: typeof _onCodeComplete) { export function setOnCodeComplete(fn: typeof _onCodeComplete) {
_onCodeComplete = fn _onCodeComplete = fn
@@ -109,11 +115,21 @@ export async function loadHistory(taskId: number) {
historyLoading.value = true historyLoading.value = true
try { try {
const convs = await Prompt.listConversations(taskId) const convs = await Prompt.listConversations(taskId)
console.log("[loadHistory] convs:", convs.map((c: any) => ({ id: c.id, is_active: c.is_active, message_count: c.message_count, username: c.username })), "user.username:", user.username) console.log(
"[loadHistory] convs:",
convs.map((c: any) => ({
id: c.id,
is_active: c.is_active,
message_count: c.message_count,
username: c.username,
})),
"user.username:",
user.username,
)
if (loadId !== _historyLoadId) return // navigated away, abort if (loadId !== _historyLoadId) return // navigated away, abort
const active = convs.find( const active = convs.find(
(c: { is_active: boolean; message_count: number; username: string }) => (c: { is_active: boolean; message_count: number; username: string }) =>
c.is_active && c.message_count > 0 && c.username === user.username c.is_active && c.message_count > 0 && c.username === user.username,
) )
console.log("[loadHistory] active:", active) console.log("[loadHistory] active:", active)
if (!active) return if (!active) return
@@ -157,7 +173,11 @@ export function newConversation() {
ws.send(JSON.stringify({ type: "new_conversation" })) ws.send(JSON.stringify({ type: "new_conversation" }))
} }
function applyCode(code: { html: string | null; css: string | null; js: string | null }) { function applyCode(code: {
html: string | null
css: string | null
js: string | null
}) {
if (code.html !== null) html.value = code.html if (code.html !== null) html.value = code.html
if (code.css !== null) css.value = code.css if (code.css !== null) css.value = code.css
if (code.js !== null) js.value = code.js if (code.js !== null) js.value = code.js

View File

@@ -39,7 +39,8 @@ export const ADMIN_URL = import.meta.env.PUBLIC_ADMIN_URL
export const BASE_URL = import.meta.env.PUBLIC_BASE_URL export const BASE_URL = import.meta.env.PUBLIC_BASE_URL
export const WS_BASE_URL = import.meta.env.PUBLIC_WS_URL || `ws://${window.location.host}` export const WS_BASE_URL =
import.meta.env.PUBLIC_WS_URL || `ws://${window.location.host}`
export enum TASK_TYPE { export enum TASK_TYPE {
Tutorial = "tutorial", Tutorial = "tutorial",

View File

@@ -101,3 +101,53 @@ export interface SubmissionAll {
created: Date created: Date
modified: Date modified: Date
} }
export interface UserTag {
username: string
classname: string
}
export interface TopSubmission {
submission_id: string
username: string
classname: string
score: number
rating_count: number
}
export interface SubmissionCountBucket {
count_1: number
count_2: number
count_3: number
count_4_plus: number
}
export interface ScoreBucket {
range_1_2: number
range_2_3: number
range_3_4: number
range_4_5: number
range_5: number
}
export interface FlagStats {
red: number
blue: number
green: number
yellow: number
}
export interface TaskStatsOut {
submitted_count: number
unsubmitted_count: number
average_score: number | null
unrated_count: number
nominated_count: number
unsubmitted_users: UserTag[]
unrated_users: UserTag[]
submission_count_distribution: SubmissionCountBucket
score_distribution: ScoreBucket
top_submissions: TopSubmission[]
flag_stats: FlagStats
classes: string[]
}

View File

@@ -10,5 +10,5 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] "include": ["src/**/*.ts", "src/**/*.vue"]
} }