update
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled

This commit is contained in:
2026-03-18 14:50:20 +08:00
parent 98d8099b5d
commit d68ef60ab9
17 changed files with 1207 additions and 314 deletions

6
components.d.ts vendored
View File

@@ -11,9 +11,13 @@ 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']
@@ -21,7 +25,6 @@ declare module 'vue' {
NCard: typeof import('naive-ui')['NCard']
NCode: typeof import('naive-ui')['NCode']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDataTable: typeof import('naive-ui')['NDataTable']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDropdown: typeof import('naive-ui')['NDropdown']
NEmpty: typeof import('naive-ui')['NEmpty']
@@ -34,7 +37,6 @@ declare module 'vue' {
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NModalProvider: typeof import('naive-ui')['NModalProvider']
NPagination: typeof import('naive-ui')['NPagination']
NPopover: typeof import('naive-ui')['NPopover']
NRate: typeof import('naive-ui')['NRate']
NSelect: typeof import('naive-ui')['NSelect']

View File

@@ -1,6 +1,6 @@
import axios from "axios"
import { router } from "./router"
import type { TutorialIn, ChallengeIn, FlagType } from "./utils/type"
import type { TutorialIn, ChallengeIn, FlagType, SubmissionOut, PromptMessage } from "./utils/type"
import { BASE_URL, STORAGE_KEY } from "./utils/const"
const http = axios.create({
@@ -72,6 +72,18 @@ export const Account = {
const res = await http.get("/account/leaderboard")
return res.data as { rank: number; username: string; total_score: number }[]
},
async listClasses(): Promise<string[]> {
const res = await http.get("/account/classes")
return res.data
},
async listNamesByClass(
classname: string,
): Promise<{ name: string; username: string }[]> {
const res = await http.get("/account/names", { params: { classname } })
return res.data
},
}
export const Tutorial = {
@@ -156,18 +168,44 @@ export const Submission = {
return res.data
},
async list(query: { page: number }) {
async list(query: {
page: number
page_size?: number
username?: string
user_id?: number
flag?: string | null
task_id?: number
task_type?: string
score_min?: number
score_max_exclusive?: number
score_lt_threshold?: number
nominated?: boolean
ordering?: string
grouped?: boolean
}) {
const res = await http.get("/submission", {
params: query,
})
return res.data
},
async listByUserTask(userId: number, taskId: number) {
const res = await http.get("/submission/by-user-task", {
params: { user_id: userId, task_id: taskId },
})
return res.data as SubmissionOut[]
},
async get(id: string) {
const res = await http.get("/submission/" + id)
return res.data
},
async delete(id: string) {
const res = await http.delete("/submission/" + id)
return res.data
},
async updateScore(id: string, score: number) {
const res = await http.put(`/submission/${id}/score`, { score })
return res.data
@@ -178,6 +216,16 @@ export const Submission = {
return res.data
},
async clearAllFlags() {
const res = await http.delete(`/submission/flags`)
return res.data as { cleared: number }
},
async nominate(id: string) {
const res = await http.put(`/submission/${id}/nominate`)
return res.data as { nominated: boolean }
},
async myScores() {
const res = await http.get("/submission/my-scores")
return res.data as {
@@ -199,9 +247,9 @@ export const Prompt = {
return (await http.get("/prompt/conversations/", { params })).data
},
async getMessages(conversationId: string) {
async getMessages(conversationId: string): Promise<PromptMessage[]> {
return (
await http.get(`/prompt/conversations/${conversationId}/messages/`)
await http.get<PromptMessage[]>(`/prompt/conversations/${conversationId}/messages/`)
).data
},
}

View File

@@ -86,6 +86,7 @@ import Editor from "./Editor.vue"
import Toolbar from "./Toolbar.vue"
import { html, css, js, tab, size, reset } from "../store/editors"
import { taskId } from "../store/task"
import { conversationId } from "../store/prompt"
import { Submission } from "../api"
import { NCode, useDialog, useMessage } from "naive-ui"
import { h, ref } from "vue"
@@ -145,6 +146,7 @@ async function doSubmit() {
html: html.value,
css: css.value,
js: js.value,
conversationId: conversationId.value || undefined,
})
message.success("提交成功")
} catch (err) {

View File

@@ -2,62 +2,216 @@
<n-modal
preset="card"
title="登录"
style="width: 400px"
style="width: 420px"
v-model:show="loginModal"
>
<n-form>
<n-form-item label="用户名">
<n-input v-model:value="name" name="username"></n-input>
</n-form-item>
<n-form-item label="密码">
<n-input
type="password"
v-model:value="password"
name="password"
></n-input>
</n-form-item>
<n-alert
type="error"
v-if="showMessage"
class="message"
title="登录失败,请检查用户名和密码"
></n-alert>
<n-flex>
<n-button block :loading="loading" @click="submit" type="primary"
>登录</n-button
>
</n-flex>
</n-form>
<n-tabs v-model:value="activeTab" @update:value="onTabChange">
<n-tab-pane name="student" tab="学生登录">
<n-form>
<n-form-item label="班级">
<n-select
v-model:value="selectedClass"
:options="classOptions"
placeholder="选择班级"
:loading="classesLoading"
@update:value="onClassChange"
/>
</n-form-item>
<n-form-item label="姓名">
<n-select
v-model:value="selectedUsername"
:options="nameOptions"
placeholder="选择姓名"
:loading="namesLoading"
:disabled="!selectedClass || namesLoading"
/>
</n-form-item>
<n-form-item label="密码">
<n-input
type="password"
v-model:value="studentPassword"
name="password"
/>
</n-form-item>
<n-alert
type="error"
v-if="classesError"
class="message"
title="加载班级列表失败,请刷新重试"
/>
<n-alert
type="error"
v-if="namesError"
class="message"
title="加载姓名列表失败,请重新选择班级"
/>
<n-alert
type="error"
v-if="showStudentError"
class="message"
title="登录失败,请检查密码"
/>
<n-button
block
type="primary"
:loading="studentLoading"
:disabled="!selectedClass || !selectedUsername || !studentPassword"
@click="submitStudent"
>
登录
</n-button>
</n-form>
</n-tab-pane>
<n-tab-pane name="admin" tab="管理员登录">
<n-form>
<n-form-item label="用户名">
<n-input v-model:value="adminName" name="username" />
</n-form-item>
<n-form-item label="密码">
<n-input
type="password"
v-model:value="adminPassword"
name="password"
/>
</n-form-item>
<n-alert
type="error"
v-if="showAdminError"
class="message"
title="登录失败,请检查用户名和密码"
/>
<n-button
block
type="primary"
:loading="adminLoading"
:disabled="!adminName || !adminPassword"
@click="submitAdmin"
>
登录
</n-button>
</n-form>
</n-tab-pane>
</n-tabs>
</n-modal>
</template>
<script lang="ts" setup>
import { ref } from "vue"
import { ref, computed, onMounted } from "vue"
import { Account } from "../api"
import { loginModal } from "../store/modal"
import { user } from "../store/user"
const name = ref("")
const password = ref("")
const loading = ref(false)
const showMessage = ref(false)
// Tab state
const activeTab = ref("student")
async function submit() {
loading.value = true
// Student tab state
const selectedClass = ref<string | null>(null)
const selectedUsername = ref<string | null>(null)
const studentPassword = ref("")
const studentLoading = ref(false)
const showStudentError = ref(false)
// Classes data
const classes = ref<string[]>([])
const classesLoading = ref(false)
const classesError = ref(false)
const classOptions = computed(() =>
classes.value.map((c) => ({ label: c, value: c })),
)
// Names data
const names = ref<{ name: string; username: string }[]>([])
const namesLoading = ref(false)
const namesError = ref(false)
const nameOptions = computed(() =>
names.value.map((n) => ({ label: n.name, value: n.username })),
)
// Admin tab state
const adminName = ref("")
const adminPassword = ref("")
const adminLoading = ref(false)
const showAdminError = ref(false)
// Load classes on mount (cached — not reloaded on tab switch)
onMounted(async () => {
classesLoading.value = true
classesError.value = false
try {
const data = await Account.login(name.value, password.value)
classes.value = await Account.listClasses()
} catch {
classesError.value = true
} finally {
classesLoading.value = false
}
})
function onTabChange() {
// Reset student tab state
selectedClass.value = null
selectedUsername.value = null
studentPassword.value = ""
showStudentError.value = false
namesError.value = false
names.value = []
// Reset admin tab state
adminName.value = ""
adminPassword.value = ""
showAdminError.value = false
}
async function onClassChange(classname: string) {
selectedUsername.value = null
names.value = []
namesError.value = false
namesLoading.value = true
try {
names.value = await Account.listNamesByClass(classname)
} catch {
namesError.value = true
} finally {
namesLoading.value = false
}
}
async function submitStudent() {
if (!selectedUsername.value || !studentPassword.value) return
studentLoading.value = true
showStudentError.value = false
try {
const data = await Account.login(selectedUsername.value, studentPassword.value)
user.username = data.username
user.role = data.role
user.loaded = true
loginModal.value = false
loading.value = false
} catch (err) {
showMessage.value = true
loading.value = false
} catch {
showStudentError.value = true
} finally {
studentLoading.value = false
}
}
async function submitAdmin() {
if (!adminName.value || !adminPassword.value) return
adminLoading.value = true
showAdminError.value = false
try {
const data = await Account.login(adminName.value, adminPassword.value)
user.username = data.username
user.role = data.role
user.loaded = true
loginModal.value = false
} catch {
showAdminError.value = true
} finally {
adminLoading.value = false
}
}
</script>
<style scoped>
.message {
margin-bottom: 20px;
margin-bottom: 12px;
}
</style>

View File

@@ -7,7 +7,7 @@
<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.submissionId" @click="copyLink">
复制链接
链接
</n-button>
<n-flex v-if="!!submission.id">
<n-button quaternary @click="emits('showCode')">代码</n-button>

View File

@@ -1,13 +1,21 @@
<template>
<div class="prompt-panel">
<div class="messages" ref="messagesRef">
<div v-if="historyLoading" class="history-loading">
<n-spin size="small" />
<span>加载历史记录</span>
</div>
<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-content" v-html="renderContent(msg)"></div>
</div>
<div v-if="streaming" class="message assistant">
<div class="message-role">AI</div>
<div class="message-content" v-html="renderMarkdown(streamingContent)"></div>
<div v-if="!streamingContent" class="typing-indicator">
<span></span><span></span><span></span>
</div>
<div v-else class="message-content" v-html="renderMarkdown(streamingContent)"></div>
<div class="streaming-hint">AI 正在思考中</div>
</div>
</div>
<div class="input-area">
@@ -45,6 +53,7 @@ import {
streamingContent,
sendPrompt,
newConversation,
historyLoading,
} from "../store/prompt"
const input = ref("")
@@ -58,10 +67,16 @@ function send() {
}
const renderer = new Renderer()
renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
const escape = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
return `<pre><code class="hljs${lang ? ` language-${escape(lang)}` : ""}">${text}</code></pre>`
renderer.code = function ({ lang }: { text: string; lang?: string }) {
const label = lang ? lang.toUpperCase() : "CODE"
const colors: Record<string, { bg: string; fg: string; dot: string; border: string; shimmer: string }> = {
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" }
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>`
}
function renderMarkdown(text: string): string {
@@ -131,9 +146,96 @@ watch(
font-size: 13px;
}
.message-content :deep(.code-placeholder) {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin: 8px 0;
background: linear-gradient(90deg, #f0f7ff, #e8f4f8, #f0f7ff);
background-size: 200% 100%;
animation: shimmer 2s ease-in-out infinite;
border-radius: 6px;
border: 1px solid #e0eaf5;
}
.message-content :deep(.code-placeholder-dot) {
width: 8px;
height: 8px;
border-radius: 50%;
background: #2080f0;
animation: pulse 1.5s ease-in-out infinite;
}
.message-content :deep(.code-placeholder-label) {
font-size: 11px;
font-weight: 600;
padding: 1px 6px;
border-radius: 3px;
}
.message-content :deep(.code-placeholder-text) {
font-size: 12px;
color: #888;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.typing-indicator {
display: flex;
gap: 5px;
padding: 8px 0;
}
.typing-indicator span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #18a058;
animation: bounce 1.4s ease-in-out infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-6px); opacity: 1; }
}
.streaming-hint {
font-size: 11px;
color: #aaa;
margin-top: 4px;
animation: pulse 1.5s ease-in-out infinite;
}
.input-area {
padding: 12px;
border-top: 1px solid #e0e0e0;
}
.history-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
color: #aaa;
font-size: 13px;
justify-content: center;
}
</style>

View File

@@ -57,6 +57,7 @@ const menu = computed(() => [
icon: () =>
h(Icon, {
icon: "streamline-emojis:robot-face-1",
width: 20,
}),
},
{
@@ -66,6 +67,7 @@ const menu = computed(() => [
icon: () =>
h(Icon, {
icon: "skill-icons:django",
width: 20,
}),
},
{
@@ -74,6 +76,16 @@ const menu = computed(() => [
icon: () =>
h(Icon, {
icon: "streamline-emojis:bar-chart",
width: 20,
}),
},
{
label: "排名榜",
key: "ranking",
icon: () =>
h(Icon, {
icon: "streamline-emojis:sunglasses",
width: 20,
}),
},
{
@@ -82,6 +94,7 @@ const menu = computed(() => [
icon: () =>
h(Icon, {
icon: "streamline-emojis:hot-beverage-2",
width: 20,
}),
},
])
@@ -106,6 +119,9 @@ function clickMenu(name: string) {
query: { username: user.username },
})
break
case "ranking":
router.push({ name: "ranking" })
break
case "logout":
handleLogout()
break

View File

@@ -0,0 +1,148 @@
<template>
<n-modal
:show="show"
preset="card"
title="提示词"
style="width: 90vw; max-width: 1400px"
@update:show="$emit('update:show', $event)"
>
<n-spin :show="loading">
<n-empty v-if="!loading && rounds.length === 0" description="暂无对话记录" />
<div
v-else
style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; height: 75vh"
>
<div
style="
overflow-y: auto;
padding-right: 8px;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
gap: 8px;
"
>
<div
v-for="(round, index) in rounds"
:key="index"
style="display: flex; gap: 10px; align-items: flex-start; cursor: pointer"
@click="selectedRound = index"
>
<div
:style="{
flexShrink: 0, width: '22px', height: '22px', borderRadius: '50%',
background: selectedRound === index ? '#2080f0' : '#c2d5fb',
color: '#fff', fontSize: '12px', fontWeight: 'bold',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginTop: '2px', transition: 'background 0.2s',
}"
>
{{ index + 1 }}
</div>
<div
:style="{
flex: 1, padding: '10px 14px', borderRadius: '8px',
background: selectedRound === index ? '#e8f0fe' : '#f5f5f5',
border: selectedRound === index ? '1px solid #2080f0' : '1px solid #e0e0e0',
fontSize: '13px', lineHeight: '1.6', transition: 'all 0.2s',
}"
>
{{ round.question }}
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 8px">
<div style="font-weight: bold; font-size: 13px; color: #555">
{{ selectedRound + 1 }} 轮网页
</div>
<iframe
v-if="selectedPageHtml"
:srcdoc="selectedPageHtml"
:key="selectedRound"
sandbox="allow-scripts"
style="flex: 1; border: 1px solid #e0e0e0; border-radius: 6px; background: #fff"
/>
<n-empty v-else description="该轮无网页代码" style="margin: auto" />
</div>
</div>
</n-spin>
</n-modal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { Prompt } from "../../api"
import type { PromptMessage } from "../../utils/type"
const props = defineProps<{
show: boolean
conversationId?: string
}>()
defineEmits<{ "update:show": [value: boolean] }>()
const loading = ref(false)
const messages = ref<PromptMessage[]>([])
const selectedRound = ref(0)
const rounds = computed(() => {
const result: { question: string; html: string | null; css: string | null; js: string | null }[] = []
for (const [i, msg] of messages.value.entries()) {
if (msg.role !== "user") continue
let html: string | null = null, css: string | null = null, js: string | null = null
for (const reply of messages.value.slice(i + 1)) {
if (reply.role === "user") break
if (reply.role === "assistant" && reply.code_html) {
html = reply.code_html
css = reply.code_css
js = reply.code_js
break
}
}
result.push({ question: msg.content, html, css, js })
}
return result
})
const selectedPageHtml = computed(() => {
const round = rounds.value[selectedRound.value]
if (!round?.html) return null
const style = round.css ? `<style>${round.css}</style>` : ""
const script = round.js ? `<script>${round.js}<\/script>` : ""
return `<!DOCTYPE html><html><head><meta charset="utf-8">${style}</head><body>${round.html}${script}</body></html>`
})
watch(
() => props.conversationId,
async (id) => {
if (!id || !props.show) return
loading.value = true
messages.value = []
selectedRound.value = 0
try {
messages.value = await Prompt.getMessages(id)
const last = rounds.value.length - 1
if (last >= 0) selectedRound.value = last
} finally {
loading.value = false
}
},
)
watch(
() => props.show,
async (visible) => {
if (!visible || !props.conversationId) return
loading.value = true
messages.value = []
selectedRound.value = 0
try {
messages.value = await Prompt.getMessages(props.conversationId)
const last = rounds.value.length - 1
if (last >= 0) selectedRound.value = last
} finally {
loading.value = false
}
},
)
</script>

View File

@@ -0,0 +1,35 @@
<template>
<n-modal preset="card" :show="show" style="max-width: 60%" @update:show="$emit('update:show', $event)">
<template #header>
<n-flex align="center">
<span>前端代码</span>
<n-button tertiary @click="$emit('copy-to-editor')">复制到编辑框</n-button>
</n-flex>
</template>
<n-tabs animated type="segment">
<n-tab-pane name="html" tab="html">
<n-code :code="html" language="html" word-wrap />
</n-tab-pane>
<n-tab-pane name="css" tab="css">
<n-code :code="css" language="css" word-wrap />
</n-tab-pane>
<n-tab-pane v-if="!!js" name="js" tab="js">
<n-code :code="js" language="js" word-wrap />
</n-tab-pane>
</n-tabs>
</n-modal>
</template>
<script setup lang="ts">
defineProps<{
show: boolean
html: string
css: string
js: string
}>()
defineEmits<{
"update:show": [value: boolean]
"copy-to-editor": []
}>()
</script>

View File

@@ -0,0 +1,107 @@
<template>
<n-spin v-if="loading" size="small" style="padding: 12px" />
<n-data-table
v-else-if="items"
:columns="subColumns"
:data="items"
size="small"
striped
:row-key="(r: SubmissionOut) => r.id"
:row-props="rowProps"
:row-class-name="rowClassName"
/>
</template>
<script setup lang="ts">
import { computed, h } from "vue"
import { NButton, NDataTable, NPopconfirm, NSpin, type DataTableColumn } from "naive-ui"
import type { SubmissionOut } from "../../utils/type"
import { TASK_TYPE } from "../../utils/const"
import { parseTime } from "../../utils/helper"
import { user } from "../../store/user"
import { submission } from "../../store/submission"
const props = defineProps<{
row: SubmissionOut
items: SubmissionOut[] | undefined
loading: boolean
}>()
const emit = defineEmits<{
select: [id: string]
delete: [row: SubmissionOut, parentId: string]
"show-chain": [conversationId: string]
}>()
const isChallenge = computed(() => props.row.task_type === TASK_TYPE.Challenge)
function rowProps(r: SubmissionOut) {
return {
style: { cursor: "pointer" },
onClick: () => emit("select", r.id),
}
}
function rowClassName(r: SubmissionOut) {
return submission.value.id === r.id ? "row-active" : ""
}
const subColumns = computed((): DataTableColumn<SubmissionOut>[] => [
{
title: "时间",
key: "created",
width: 160,
render: (r) => parseTime(r.created, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "得分",
key: "score",
width: 80,
render: (r) => {
const myScore = r.my_score > 0 ? String(r.my_score) : "-"
const avgScore = r.score > 0 ? r.score.toFixed(2) : "-"
return h("div", { style: { display: "flex", gap: "6px", alignItems: "baseline" } }, [
h("span", avgScore),
h("span", { style: { fontSize: "11px", color: "#999" } }, myScore),
])
},
},
...(isChallenge.value
? [{
title: "提示词",
key: "conversation_id",
width: 70,
render: (r: SubmissionOut) => {
if (!r.conversation_id) return "-"
return h(
NButton,
{ text: true, type: "primary", onClick: (e: Event) => { e.stopPropagation(); emit("show-chain", r.conversation_id!) } },
() => "查看",
)
},
} as DataTableColumn<SubmissionOut>]
: []),
...(!isChallenge.value
? [{
title: "操作",
key: "actions",
width: 60,
render: (r: SubmissionOut) => {
if (r.username !== user.username) return null
return h(
NPopconfirm,
{ onPositiveClick: () => emit("delete", r, props.row.id) },
{
trigger: () => h(
NButton,
{ text: true, type: "error", size: "small", onClick: (e: Event) => e.stopPropagation() },
() => "删除",
),
default: () => "确定删除这次提交",
},
)
},
} as DataTableColumn<SubmissionOut>]
: []),
])
</script>

View File

@@ -0,0 +1,61 @@
<template>
<n-popover v-if="isAdmin" trigger="click">
<template #trigger>
<span :style="dotStyle" />
</template>
<n-space vertical size="small">
<n-button
v-for="opt in FLAG_OPTIONS"
:key="opt.value"
text
@click="$emit('update:flag', opt.value)"
>
<span style="display: flex; align-items: center; gap: 6px">
<span
:style="{
display: 'inline-block', width: '10px', height: '10px',
borderRadius: '50%', backgroundColor: opt.color,
}"
/>
{{ opt.label }}
</span>
</n-button>
<n-button v-if="flag" text block type="error" @click="$emit('update:flag', null)">
清除
</n-button>
</n-space>
</n-popover>
<span v-else :style="dotStyle" />
</template>
<script setup lang="ts">
import { computed } from "vue"
import type { FlagType } from "../../utils/type"
const FLAG_OPTIONS: { value: NonNullable<FlagType>; color: string; label: string }[] = [
{ value: "red", color: "#e03030", label: "值得展示" },
{ value: "blue", color: "#2080f0", label: "需要讲解" },
{ value: "green", color: "#18a058", label: "优秀作品" },
{ value: "yellow", color: "#f0a020", label: "需要改进" },
]
const props = defineProps<{
flag: FlagType
isAdmin: boolean
}>()
defineEmits<{ "update:flag": [value: FlagType] }>()
const dotStyle = computed(() => {
const match = FLAG_OPTIONS.find((f) => f.value === props.flag)
return {
display: "inline-block",
width: "12px",
height: "12px",
borderRadius: "50%",
backgroundColor: match ? match.color : "transparent",
border: match ? "none" : "1px dashed #ccc",
cursor: props.isAdmin ? "pointer" : "default",
}
})
</script>

View File

@@ -49,7 +49,7 @@ import Preview from "../components/Preview.vue"
import { Challenge, Submission } from "../api"
import { html, css, js } from "../store/editors"
import { taskId } from "../store/task"
import { connectPrompt, disconnectPrompt, conversationId, streaming, setOnCodeComplete } from "../store/prompt"
import { connectPrompt, disconnectPrompt, conversationId, streaming, setOnCodeComplete, loadHistory } from "../store/prompt"
const route = useRoute()
const router = useRouter()
@@ -71,7 +71,8 @@ async function loadChallenge() {
taskId.value = data.task_ptr
challengeTitle.value = `#${data.display} ${data.title}`
challengeContent.value = await marked.parse(data.content, { async: true })
connectPrompt(data.task_ptr)
loadHistory(data.task_ptr) // HTTP preload — async, non-blocking
connectPrompt(data.task_ptr) // WebSocket — synchronous open
setOnCodeComplete(async (code) => {
if (!conversationId.value) return
try {

222
src/pages/Ranking.vue Normal file
View File

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

@@ -1,47 +1,50 @@
<template>
<n-split class="container" direction="horizontal" :default-size="0.333" :min="0.2" :max="0.8">
<n-split
class="container"
direction="horizontal"
:default-size="0.5"
:min="0.2"
:max="0.8"
>
<template #1>
<n-flex vertical style="height: 100%; padding-right: 10px">
<n-flex justify="space-between">
<n-flex vertical style="height: 100%; padding-right: 10px; overflow: hidden">
<n-flex justify="space-between" style="flex-shrink: 0">
<n-button secondary @click="() => goHome($router, taskTab, step)">
返回首页
</n-button>
<n-flex align="center">
<n-select
v-model:value="query.flag"
:value="query.flag"
style="width: 100px"
clearable
placeholder="标记"
:options="[
{ label: '红旗', value: 'red' },
{ label: '蓝旗', value: 'blue' },
{ label: '绿旗', value: 'green' },
{ label: '黄旗', value: 'yellow' },
]"
:options="flagFilterOptions"
@update:value="handleFlagSelect"
/>
<div>
<n-input
style="width: 120px"
v-model:value="query.username"
clearable
/>
</div>
<n-input style="width: 120px" v-model:value="query.username" clearable />
<n-pagination
v-model:page="query.page"
:page-size="10"
:item-count="count"
simple
>
</n-pagination>
/>
<n-button secondary style="padding: 0 10px" title="刷新" @click="init">
<Icon :width="16" icon="lucide:refresh-cw" />
</n-button>
</n-flex>
</n-flex>
<n-data-table
flex-height
striped
:columns="columns"
:data="data"
:row-key="(row: SubmissionOut) => row.id"
:expanded-row-keys="expandedKeys"
@update:expanded-row-keys="handleExpand"
:row-props="rowProps"
:row-class-name="rowClassName"
></n-data-table>
style="flex: 1; min-height: 0"
/>
</n-flex>
</template>
<template #2>
@@ -58,233 +61,152 @@
</div>
</template>
</n-split>
<n-modal preset="card" v-model:show="codeModal" style="max-width: 60%">
<template #header>
<n-flex align="center">
<span>前端代码</span>
<n-button tertiary @click="copyToEditor">复制到编辑框</n-button>
</n-flex>
</template>
<n-tabs animated type="segment">
<n-tab-pane name="html" tab="html">
<n-code :code="html" language="html" word-wrap></n-code>
</n-tab-pane>
<n-tab-pane name="css" tab="css">
<n-code :code="css" language="css" word-wrap></n-code>
</n-tab-pane>
<n-tab-pane v-if="!!js" name="js" tab="js">
<n-code :code="js" language="js" word-wrap></n-code>
</n-tab-pane>
</n-tabs>
</n-modal>
<n-modal v-model:show="chainModal" preset="card" title="提示词" style="width: 90vw; max-width: 1400px">
<n-spin :show="chainLoading">
<n-empty v-if="!chainLoading && chainRounds.length === 0" description="暂无对话记录" />
<div v-else style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; height: 75vh">
<!-- 左侧学生提问列表 -->
<div style="overflow-y: auto; padding-right: 8px; border-right: 1px solid #e0e0e0; display: flex; flex-direction: column; gap: 8px">
<div
v-for="(round, index) in chainRounds"
:key="index"
style="display: flex; gap: 10px; align-items: flex-start; cursor: pointer"
@click="selectedRound = index"
>
<div :style="{
flexShrink: 0, width: '22px', height: '22px', borderRadius: '50%',
background: selectedRound === index ? '#2080f0' : '#c2d5fb',
color: '#fff', fontSize: '12px', fontWeight: 'bold',
display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: '2px',
transition: 'background 0.2s',
}">
{{ index + 1 }}
</div>
<div :style="{
flex: 1, padding: '10px 14px', borderRadius: '8px',
background: selectedRound === index ? '#e8f0fe' : '#f5f5f5',
border: selectedRound === index ? '1px solid #2080f0' : '1px solid #e0e0e0',
fontSize: '13px', lineHeight: '1.6', transition: 'all 0.2s',
}">
{{ round.question }}
</div>
</div>
</div>
<!-- 右侧对应网页预览 -->
<div style="display: flex; flex-direction: column; gap: 8px">
<div style="font-weight: bold; font-size: 13px; color: #555">
{{ selectedRound + 1 }} 轮网页
</div>
<iframe
v-if="selectedPageHtml"
:srcdoc="selectedPageHtml"
:key="selectedRound"
sandbox="allow-scripts"
style="flex: 1; border: 1px solid #e0e0e0; border-radius: 6px; background: #fff"
/>
<n-empty v-else description="该轮无网页代码" style="margin: auto" />
</div>
</div>
</n-spin>
</n-modal>
<CodeModal
v-model:show="codeModal"
:html="html"
:css="css"
:js="js"
@copy-to-editor="copyToEditor"
/>
<ChainModal
v-model:show="chainModal"
:conversation-id="chainConversationId"
/>
</template>
<script setup lang="ts">
import { NButton, NPopover, NSpace, type DataTableColumn } from "naive-ui"
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
import { Submission, Prompt } from "../api"
import type { SubmissionOut } from "../utils/type"
import { NButton, NDataTable, type DataTableColumn } from "naive-ui"
import { Icon } from "@iconify/vue"
import { Submission } from "../api"
import type { SubmissionOut, FlagType } from "../utils/type"
import { parseTime } from "../utils/helper"
import TaskTitle from "../components/submissions/TaskTitle.vue"
import Preview from "../components/Preview.vue"
import { submission } from "../store/submission"
import { useRouter, useRoute } from "vue-router"
import { goHome } from "../utils/helper"
import { TASK_TYPE } from "../utils/const"
import { watchDebounced } from "@vueuse/core"
import { useRouter, useRoute } from "vue-router"
import Preview from "../components/Preview.vue"
import TaskTitle from "../components/submissions/TaskTitle.vue"
import CodeModal from "../components/submissions/CodeModal.vue"
import ChainModal from "../components/submissions/ChainModal.vue"
import FlagCell from "../components/submissions/FlagCell.vue"
import ExpandedSubTable from "../components/submissions/ExpandedSubTable.vue"
import { submission } from "../store/submission"
import { taskTab } from "../store/task"
import { step } from "../store/tutorial"
import { html as eHtml, css as eCss, js as eJs } from "../store/editors"
import { TASK_TYPE } from "../utils/const"
import { goHome } from "../utils/helper"
import { roleAdmin, roleSuper } from "../store/user"
import type { FlagType } from "../utils/type"
import { roleAdmin, roleSuper, user } from "../store/user"
const route = useRoute()
const router = useRouter()
// 列表数据
const data = ref<SubmissionOut[]>([])
const count = ref(0)
const query = reactive({
page: Number(route.params.page),
username: route.query.username ?? "",
username: (Array.isArray(route.query.username) ? "" : (route.query.username ?? "")) as string,
flag: null as string | null,
})
// 当前选中提交的代码
const html = computed(() => submission.value.html)
const css = computed(() => submission.value.css)
const js = computed(() => submission.value.js)
// Modal 状态
const codeModal = ref(false)
const chainModal = ref(false)
const chainMessages = ref<{ id: number; role: string; content: string; code_html: string | null; code_css: string | null; code_js: string | null }[]>([])
const chainLoading = ref(false)
const selectedRound = ref(0)
const chainConversationId = ref<string | undefined>()
const FLAG_OPTIONS: { value: FlagType; color: string; label: string }[] = [
{ value: "red", color: "#e03030", label: "值得展示" },
{ value: "blue", color: "#2080f0", label: "需要讲解" },
{ value: "green", color: "#18a058", label: "优秀作品" },
{ value: "yellow", color: "#f0a020", label: "需要改进" },
]
// 展开行
const expandedKeys = ref<string[]>([])
const expandedData = reactive(new Map<string, SubmissionOut[]>())
const expandedLoading = reactive(new Set<string>())
// 管理员判断
const isAdmin = computed(() => roleAdmin.value || roleSuper.value)
const chainRounds = computed(() => {
const messages = chainMessages.value
const rounds: { question: string; html: string | null; css: string | null; js: string | null }[] = []
for (let i = 0; i < messages.length; i++) {
if (messages[i].role !== "user") continue
let html = null, css = null, js = null
for (let j = i + 1; j < messages.length; j++) {
if (messages[j].role === "user") break
if (messages[j].role === "assistant" && messages[j].code_html) {
html = messages[j].code_html
css = messages[j].code_css
js = messages[j].code_js
break
}
}
rounds.push({ question: messages[i].content, html, css, js })
}
return rounds
// Flag 过滤选项
const flagFilterOptions = computed(() => {
const opts = [
{ label: "红旗", value: "red" },
{ label: "蓝旗", value: "blue" },
{ label: "绿旗", value: "green" },
{ label: "黄旗", value: "yellow" },
{ label: "全部", value: "any" },
]
if (isAdmin.value) opts.push({ label: "清除", value: "_clear_all" })
return opts
})
const selectedPageHtml = computed(() => {
const round = chainRounds.value[selectedRound.value]
if (!round?.html) return null
const style = round.css ? `<style>${round.css}</style>` : ""
const script = round.js ? `<script>${round.js}<\/script>` : ""
return `<!DOCTYPE html><html><head><meta charset="utf-8">${style}</head><body>${round.html}${script}</body></html>`
})
function handleFlagSelect(value: string | null) {
if (value === "_clear_all") { clearAllFlags(); return }
query.flag = value
}
async function updateFlag(row: SubmissionOut, flag: FlagType) {
await Submission.updateFlag(row.id, flag)
row.flag = flag
}
async function showChain(conversationId: string) {
chainLoading.value = true
chainModal.value = true
selectedRound.value = 0
try {
chainMessages.value = await Prompt.getMessages(conversationId)
const last = chainRounds.value.length - 1
if (last >= 0) selectedRound.value = last
} finally {
chainLoading.value = false
}
async function clearAllFlags() {
await Submission.clearAllFlags()
data.value = data.value.map((d) => ({ ...d, flag: null }))
query.flag = null
}
function showChain(conversationId: string) {
chainConversationId.value = conversationId
chainModal.value = true
}
// 表格列定义
const columns: DataTableColumn<SubmissionOut>[] = [
{
type: "expand",
expandable: () => true,
renderExpand: (row) =>
h(ExpandedSubTable, {
row,
items: expandedData.get(row.id),
loading: expandedLoading.has(row.id),
onSelect: (id) => getSubmissionByID(id),
onDelete: (r, parentId) => handleDelete(r, parentId),
"onShow-chain": (id) => showChain(id),
}),
},
{
title: "",
key: "flag",
width: 50,
render: (row) =>
h(FlagCell, {
flag: row.flag ?? null,
isAdmin: isAdmin.value,
"onUpdate:flag": (flag: FlagType) => updateFlag(row, flag),
}),
},
{
title: "排名",
key: "nominated",
width: 60,
render: (row) => {
const flagOption = FLAG_OPTIONS.find((f) => f.value === row.flag)
const flagIcon = h("span", {
style: {
display: "inline-block",
width: "12px",
height: "12px",
borderRadius: "50%",
backgroundColor: flagOption ? flagOption.color : "transparent",
border: flagOption ? "none" : "1px dashed #ccc",
cursor: isAdmin.value ? "pointer" : "default",
},
})
if (!isAdmin.value) return flagIcon
if (row.username !== user.username) {
return row.nominated ? h("span", { style: { color: "#f0a020" } }, "🏅") : null
}
return h(
NPopover,
{ trigger: "click" },
NButton,
{
trigger: () => flagIcon,
default: () =>
h(NSpace, { vertical: true, size: "small" }, () => [
...FLAG_OPTIONS.map((opt) =>
h(
NButton,
{
text: true,
onClick: () => updateFlag(row, opt.value),
},
() =>
h("span", { style: { display: "flex", alignItems: "center", gap: "6px" } }, [
h("span", {
style: {
display: "inline-block",
width: "10px",
height: "10px",
borderRadius: "50%",
backgroundColor: opt.color,
},
}),
opt.label,
]),
),
),
row.flag
? h(
NButton,
{
text: true,
block: true,
type: "error",
onClick: () => updateFlag(row, null),
},
() => "清除",
)
: null,
]),
text: true,
title: row.nominated ? "已参与排名点击可重新提名" : "参与排名",
onClick: (e: Event) => { e.stopPropagation(); handleNominate(row) },
},
() => (row.nominated ? "🏅" : ""),
)
},
},
@@ -303,12 +225,12 @@ const columns: DataTableColumn<SubmissionOut>[] = [
{
title: "任务",
key: "task_title",
render: (submission) => h(TaskTitle, { submission }),
render: (row) => h(TaskTitle, { submission: row }),
},
{
title: "得分",
key: "score",
width: 80,
width: 70,
render: (row) => {
const myScore = row.my_score > 0 ? String(row.my_score) : "-"
const avgScore = row.score > 0 ? row.score.toFixed(2) : "-"
@@ -319,32 +241,52 @@ const columns: DataTableColumn<SubmissionOut>[] = [
},
},
{
title: "提示词",
key: "conversation_id",
width: 70,
render: (row) => {
if (!row.conversation_id) return "-"
return h(
NButton,
{ text: true, type: "primary", onClick: () => showChain(row.conversation_id!) },
() => "查看",
)
},
title: "次数",
key: "submit_count",
width: 60,
render: (row) => row.submit_count || "-",
},
]
function rowProps(row: SubmissionOut) {
return {
style: { cursor: "pointer" },
onClick: () => getSubmissionByID(row.id),
async function handleExpand(keys: (string | number)[]) {
const strKeys = keys.map(String)
const newKey = strKeys.find((k) => !expandedKeys.value.includes(k))
expandedKeys.value = strKeys
if (newKey) {
const row = data.value.find((d) => d.id === newKey)
if (row && !expandedData.has(newKey)) {
expandedLoading.add(newKey)
try {
const items = await Submission.listByUserTask(row.userid, row.task_id)
expandedData.set(newKey, items)
} finally {
expandedLoading.delete(newKey)
}
}
}
}
async function handleDelete(row: SubmissionOut, parentId: string) {
await Submission.delete(row.id)
const items = expandedData.get(parentId)
if (items) expandedData.set(parentId, items.filter((d) => d.id !== row.id))
if (submission.value.id === row.id) submission.value.id = ""
const res = await Submission.list(query)
data.value = res.items
count.value = res.count
}
function rowProps(row: SubmissionOut) {
return { style: { cursor: "pointer" }, onClick: () => getSubmissionByID(row.id) }
}
function rowClassName(row: SubmissionOut) {
return submission.value.id === row.id ? "row-active" : ""
}
async function init() {
expandedKeys.value = []
expandedData.clear()
const res = await Submission.list(query)
data.value = res.items
count.value = res.count
@@ -354,11 +296,19 @@ async function getSubmissionByID(id: string) {
submission.value = await Submission.get(id)
}
async function handleNominate(row: SubmissionOut) {
await Submission.nominate(row.id)
data.value = data.value.map((d) => {
if (d.username === user.username && d.task_id === row.task_id) {
d.nominated = d.id === row.id
}
return d
})
}
function afterScore() {
data.value = data.value.map((d) => {
if (d.id === submission.value.id) {
d.my_score = submission.value.my_score
}
if (d.id === submission.value.id) d.my_score = submission.value.my_score
return d
})
}
@@ -370,55 +320,26 @@ function copyToEditor() {
goHome(router, submission.value.task_type, submission.value.task_display)
}
watch(
() => query.page,
(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()
},
)
watch(() => query.page, (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)
onUnmounted(() => {
submission.value = {
id: "",
userid: 0,
username: "",
task_id: 0,
task_display: 0,
task_title: "",
id: "", userid: 0, username: "",
task_id: 0, task_display: 0, task_title: "",
task_type: TASK_TYPE.Tutorial,
score: 0,
my_score: 0,
html: "",
css: "",
js: "",
created: new Date(),
modified: new Date(),
score: 0, my_score: 0, html: "", css: "", js: "",
created: new Date(), modified: new Date(),
}
})
</script>
<style scoped>
.container {
padding: 10px;
box-sizing: border-box;
height: calc(100% - 43px);
width: 100%;
}

View File

@@ -30,6 +30,12 @@ const routes = [
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",

View File

@@ -1,6 +1,9 @@
import { ref } from "vue"
import { WS_BASE_URL } from "../utils/const"
import { html, css, js } from "./editors"
import { Prompt } from "../api"
import type { PromptMessage as RawMessage } from "../utils/type"
import { user } from "./user"
export interface PromptMessage {
role: "user" | "assistant"
@@ -13,6 +16,8 @@ export const messages = ref<PromptMessage[]>([])
export const conversationId = ref<string>("")
export const connected = ref(false)
export const streaming = ref(false)
export const historyLoading = ref(false)
let _historyLoadId = 0
export const streamingContent = ref("")
let _onCodeComplete: ((code: { html: string | null; css: string | null; js: string | null }) => void) | null = null
@@ -35,14 +40,20 @@ export function connectPrompt(taskId: number) {
const data = JSON.parse(event.data)
if (data.type === "init") {
// Skip overwriting messages if HTTP preload already loaded this conversation.
// If conversation_id differs (e.g. after "新对话"), always overwrite.
const alreadyLoaded = conversationId.value === data.conversation_id
conversationId.value = data.conversation_id
messages.value = data.messages || []
// Apply code from last assistant message if exists
const lastAssistant = [...messages.value]
.reverse()
.find((m) => m.role === "assistant" && m.code)
if (lastAssistant?.code) {
applyCode(lastAssistant.code)
if (!alreadyLoaded) {
messages.value = data.messages || []
// Apply code from last assistant message if exists
// (skipped when HTTP preload already loaded and applied)
const lastAssistant = [...messages.value]
.reverse()
.find((m) => m.role === "assistant" && m.code)
if (lastAssistant?.code) {
applyCode(lastAssistant.code)
}
}
} else if (data.type === "stream") {
streaming.value = true
@@ -79,6 +90,8 @@ export function connectPrompt(taskId: number) {
}
export function disconnectPrompt() {
_historyLoadId++ // cancel any in-flight loadHistory
historyLoading.value = false // reset here; finally block won't (loadId mismatch)
if (ws) {
ws.close()
ws = null
@@ -91,6 +104,48 @@ export function disconnectPrompt() {
_onCodeComplete = null
}
export async function loadHistory(taskId: number) {
const loadId = ++_historyLoadId
historyLoading.value = true
try {
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)
if (loadId !== _historyLoadId) return // navigated away, abort
const active = convs.find(
(c: { is_active: boolean; message_count: number; username: string }) =>
c.is_active && c.message_count > 0 && c.username === user.username
)
console.log("[loadHistory] active:", active)
if (!active) return
const raw: RawMessage[] = await Prompt.getMessages(active.id)
console.log("[loadHistory] raw messages:", raw.length)
if (loadId !== _historyLoadId) return // navigated away, abort
// Only apply if nothing has arrived via WebSocket yet
if (messages.value.length > 0) return
conversationId.value = active.id
messages.value = raw.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
code:
m.role === "assistant"
? { html: m.code_html, css: m.code_css, js: m.code_js }
: undefined,
created: m.created,
}))
// Apply code from last assistant message to editors
const lastAssistant = [...messages.value]
.reverse()
.find((m) => m.role === "assistant" && m.code)
if (lastAssistant?.code) {
applyCode(lastAssistant.code)
}
} catch {
// 静默失败,不影响 WebSocket 正常流程
} finally {
if (loadId === _historyLoadId) historyLoading.value = false
}
}
export function sendPrompt(content: string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
messages.value.push({ role: "user", content })

View File

@@ -1,5 +1,15 @@
import type { TASK_TYPE } from "./const"
export interface PromptMessage {
id: number
role: string
content: string
code_html: string | null
code_css: string | null
code_js: string | null
created: string
}
export enum Role {
Super = "super",
Admin = "admin",
@@ -60,6 +70,7 @@ export interface SubmissionOut {
id: string
userid: number
username: string
task_id: number
task_display: number
task_type: TASK_TYPE
task_title: string
@@ -67,6 +78,8 @@ export interface SubmissionOut {
my_score: number
conversation_id?: string
flag?: FlagType
nominated: boolean
submit_count: number
created: Date
modified: Date
}