Compare commits

...

30 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
0dd5cbeee9 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 15:45:42 +08:00
d68ef60ab9 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 14:50:20 +08:00
98d8099b5d 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-16 18:23:31 +08:00
040e4e3253 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-16 17:55:49 +08:00
7e65340f63 add icon url
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-03-10 14:17:44 +08:00
2bd9382c8c fix: register plaintext hljs language and handle profile fetch error
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
- Register highlight.js plaintext language to fix "Unknown language" error
- Add try/catch around getMyProfile() to handle network errors gracefully
- Add components.d.ts to .gitignore (auto-generated by unplugin-vue-components)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 11:59:18 +08:00
a7aa4f63ac add leaderboard
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-03-09 20:00:11 +08:00
9c577f9bc1 自动提交之后提示成功提交
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-03-09 19:22:33 +08:00
016e070fb9 fix: use null instead of empty string for flag filter initial value
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
Axios sends empty string as query param which fails django-ninja
Literal validation. Null is omitted from params entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:15:35 +08:00
0ee8b0d6ea Add flag filter dropdown to submissions page
Allows filtering submissions by flag color using a select dropdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:29:00 +08:00
334b2d77b1 Add flag column with admin popover to submissions table
Admins can click the flag dot to set/clear colored flags on submissions.
Non-admins see the flag indicator as read-only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:26:16 +08:00
b4bfc7706c Add FlagType and updateFlag API method to frontend
Add FlagType to type definitions and flag field to SubmissionOut/SubmissionAll
interfaces. Add updateFlag method to Submission API client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:25:37 +08:00
f7e9d39bc2 fix AI prompt chain
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-03-09 15:02:50 +08:00
46347ff99b update 2026-03-09 10:54:24 +08:00
65022968a5 fix: complete html escape and use code arg in auto-submit callback 2026-03-09 10:53:20 +08:00
04bb023c2e feat: auto-submit on AI code generation in challenge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 10:51:17 +08:00
725fad4a55 feat: expose onCodeComplete callback in prompt store 2026-03-09 10:50:17 +08:00
4774c05809 fix: escape lang value in code block renderer 2026-03-09 10:49:44 +08:00
33d75bf83a feat: collapse code blocks in AI chat messages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 10:47:50 +08:00
7b7f6ea81d 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-09 10:01:19 +08:00
7da4becf4f add iconify url
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-03-09 09:38:44 +08:00
b6dc79b298 add chat 2026-03-04 20:05:37 +08:00
31 changed files with 2511 additions and 264 deletions

View File

@@ -2,3 +2,4 @@ PUBLIC_WEB_URL=https://web.xuyue.cc
PUBLIC_ADMIN_URL=https://web.xuyue.cc/admin
PUBLIC_BASE_URL=https://web.xuyue.cc/api
PUBLIC_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=df542e305f27dee6
PUBLIC_ICONIFY_URL=https://icon.xuyue.cc

View File

@@ -2,3 +2,4 @@ PUBLIC_WEB_URL=http://10.13.114.114:91
PUBLIC_ADMIN_URL=http://10.13.114.114:91/admin
PUBLIC_BASE_URL=http://10.13.114.114:91/api
PUBLIC_MAXKB_URL=http://10.13.114.114:92/chat/api/embed?protocol=http&host=10.13.114.114:92&token=df542e305f27dee6
PUBLIC_ICONIFY_URL=http://10.13.114.114:8098

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
components.d.ts
# Editor directories and files
.vscode/*

58
components.d.ts vendored
View File

@@ -1,58 +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 {
Challenge: typeof import('./src/components/Challenge.vue')['default']
Editor: typeof import('./src/components/Editor.vue')['default']
Editors: typeof import('./src/components/Editors.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']
NDataTable: typeof import('naive-ui')['NDataTable']
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']
NInputNumber: typeof import('naive-ui')['NInputNumber']
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']
NSplit: typeof import('naive-ui')['NSplit']
NSwitch: typeof import('naive-ui')['NSwitch']
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']
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

@@ -32,6 +32,11 @@ export default defineConfig({
target: process.env.PUBLIC_WEB_URL,
changeOrigin: true,
},
"/ws": {
target: process.env.PUBLIC_WEB_URL,
ws: true,
changeOrigin: true,
},
},
},
performance: {

View File

@@ -8,10 +8,14 @@ import { STORAGE_KEY } from "./utils/const"
import hljs from "highlight.js/lib/core"
onMounted(async () => {
try {
const data = await Account.getMyProfile()
user.loaded = true
user.username = data.username
user.role = data.role
} catch {
user.loaded = true
}
})
watch(authed, (v) => {

View File

@@ -1,6 +1,13 @@
import axios from "axios"
import { router } from "./router"
import type { TutorialIn, ChallengeIn } from "./utils/type"
import type {
TutorialIn,
ChallengeIn,
FlagType,
SubmissionOut,
PromptMessage,
TaskStatsOut,
} from "./utils/type"
import { BASE_URL, STORAGE_KEY } from "./utils/const"
const http = axios.create({
@@ -67,6 +74,18 @@ export const Account = {
const res = await http.post("/account/batch", payload)
return res.data
},
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 = {
@@ -138,29 +157,100 @@ export const Submission = {
html?: string
css?: string
js?: string
conversationId?: string
},
) {
const data = { task_id: taskId, ...code }
const { conversationId, ...rest } = code
const data = {
task_id: taskId,
...rest,
conversation_id: conversationId || null,
}
const res = await http.post("/submission/", data)
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
},
async updateFlag(id: string, flag: FlagType) {
const res = await http.put(`/submission/${id}/flag`, { flag })
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 getStats(taskId: number, classname?: string): Promise<TaskStatsOut> {
const params: Record<string, string | number> = {}
if (classname) params.classname = classname
const res = await http.get(`/submission/stats/${taskId}`, { params })
return res.data as TaskStatsOut
},
}
export const Prompt = {
async listConversations(taskId?: number, userId?: number) {
const params: Record<string, number> = {}
if (taskId) params.task_id = taskId
if (userId) params.user_id = userId
return (await http.get("/prompt/conversations/", { params })).data
},
async getMessages(conversationId: string): Promise<PromptMessage[]> {
return (
await http.get<PromptMessage[]>(
`/prompt/conversations/${conversationId}/messages/`,
)
).data
},
}
export const Helper = {

View File

@@ -62,7 +62,7 @@
<n-flex align="center">
<span class="label">预加载</span>
<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-tab-pane>
@@ -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,219 @@
<n-modal
preset="card"
title="登录"
style="width: 400px"
style="width: 420px"
v-model:show="loginModal"
>
<n-tabs v-model:value="activeTab" @update:value="onTabChange">
<n-tab-pane name="student" tab="学生登录">
<n-form>
<n-form-item label="用户名">
<n-input v-model:value="name" name="username"></n-input>
<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="password"
v-model:value="studentPassword"
name="password"
></n-input>
/>
</n-form-item>
<n-alert
type="error"
v-if="showMessage"
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-alert>
<n-flex>
<n-button block :loading="loading" @click="submit" type="primary"
>登录</n-button
/>
<n-button
block
type="primary"
:loading="adminLoading"
:disabled="!adminName || !adminPassword"
@click="submitAdmin"
>
</n-flex>
登录
</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

@@ -4,14 +4,21 @@
<n-flex>
<n-button quaternary @click="download" :disabled="!showDL">下载</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.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>
<n-button quaternary @click="emits('showCode')">代码</n-button>
<n-popover v-if="submission.my_score === 0">
<template #trigger>
<n-button secondary type="primary">手动打分</n-button>
<n-button secondary type="primary">打分</n-button>
</template>
<n-rate :size="30" @update:value="updateScore" />
</n-popover>
@@ -35,10 +42,12 @@ interface Props {
css: string
js: string
submissionId?: string
showCodeButton?: boolean
clearable?: boolean
}
const props = defineProps<Props>()
const emits = defineEmits(["afterScore", "showCode"])
const emits = defineEmits(["afterScore", "showCode", "clear"])
const message = useMessage()
const router = useRouter()
@@ -55,11 +64,11 @@ function getContent() {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>${props.css}</style>
<link rel="stylesheet" href="/normalize.min.css" />
<script src="/jquery.min.js"><\/script>
<script src="/tailwindcss.min.js"><\/script>
</head>
<body>
${props.html}
<script type="module">${props.js}<\/script>
<script>${props.js}<\/script>
</body>
</html>`
}
@@ -101,6 +110,10 @@ function open() {
}
}
function clear() {
emits("clear")
}
function copyLink() {
copy(`${document.location.origin}/submission/${props.submissionId}`)
message.success("该提交的链接已复制")

View File

@@ -0,0 +1,296 @@
<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 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">
<n-input
v-model:value="input"
type="textarea"
:autosize="{ minRows: 1, maxRows: 4 }"
placeholder="描述你想要的网页效果..."
:disabled="streaming"
@keydown.enter.exact.prevent="send"
/>
<n-flex justify="space-between" align="center" style="margin-top: 8px">
<n-button
text
size="small"
@click="newConversation"
:disabled="streaming"
>
新对话
</n-button>
<n-button
type="primary"
:loading="streaming"
:disabled="!input.trim() || streaming"
@click="send"
>
发送
</n-button>
</n-flex>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from "vue"
import { marked, Renderer } from "marked"
import {
messages,
streaming,
streamingContent,
sendPrompt,
newConversation,
historyLoading,
} from "../store/prompt"
const input = ref("")
const messagesRef = ref<HTMLElement>()
function send() {
const text = input.value.trim()
if (!text || streaming.value) return
sendPrompt(text)
input.value = ""
}
const renderer = new Renderer()
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 {
return marked.parse(text, { renderer }) as string
}
function renderContent(msg: { role: string; content: string }): string {
return renderMarkdown(msg.content)
}
// Auto-scroll to bottom on new messages
watch([() => messages.value.length, streamingContent], () => {
nextTick(() => {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
})
})
</script>
<style scoped>
.prompt-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.message {
margin-bottom: 16px;
}
.message-role {
font-size: 12px;
font-weight: bold;
margin-bottom: 4px;
color: #666;
}
.message.user .message-role {
color: #2080f0;
}
.message.assistant .message-role {
color: #18a058;
}
.message-content {
font-size: 14px;
line-height: 1.6;
}
.message-content :deep(pre) {
background: #f5f5f5;
padding: 8px 12px;
border-radius: 4px;
overflow-x: auto;
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

@@ -38,7 +38,11 @@
</template>
</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
v-if="authed"
text
@click="$router.push({ name: 'submissions', params: { page: 1 } })"
>
@@ -55,21 +59,24 @@
<Tutorial v-if="taskTab === TASK_TYPE.Tutorial" ref="tutorialRef" />
<Challenge v-else />
</div>
<TaskStatsModal v-model:show="statsModal" :task-id="taskId" />
</template>
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { computed, onMounted, ref } from "vue"
import { step } from "../store/tutorial"
import { roleSuper } from "../store/user"
import { taskTab, challengeDisplay } from "../store/task"
import { authed, roleAdmin, roleSuper } from "../store/user"
import { taskTab, taskId, challengeDisplay } from "../store/task"
import { useRoute, useRouter } from "vue-router"
import { TASK_TYPE } from "../utils/const"
import Challenge from "./Challenge.vue"
import Tutorial from "./Tutorial.vue"
import TaskStatsModal from "./TaskStatsModal.vue"
const route = useRoute()
const router = useRouter()
const tutorialRef = ref<InstanceType<typeof Tutorial>>()
const statsModal = ref(false)
defineEmits(["hide"])

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

@@ -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

@@ -11,6 +11,27 @@ import { step } from "../store/tutorial"
import { taskId } from "../store/task"
import { useRouter } from "vue-router"
marked.use({
renderer: {
code({ text, lang }) {
const language = lang?.toLowerCase() ?? "html"
return `<div class="codeblock-wrapper" data-lang="${language}">
<div class="codeblock-action">
<span class="lang">${language.toUpperCase()}</span>
<div class="btn-group">
<button class="action-btn" data-action="copy">复制</button>
<button class="action-btn" data-action="replace">替换</button>
</div>
</div>
<pre><code class="language-${language}">${text}</code></pre>
</div>`
},
link({ href, text }) {
return `<a href="${href}" target="_blank">${text}</a>`
},
},
})
const router = useRouter()
const tutorialIds = ref<number[]>([])
const content = ref("")
@@ -28,12 +49,12 @@ const nextDisabled = () => {
function prev() {
const i = tutorialIds.value.indexOf(step.value)
step.value = tutorialIds.value[i - 1]
step.value = tutorialIds.value[i - 1] as number
}
function next() {
const i = tutorialIds.value.indexOf(step.value)
step.value = tutorialIds.value[i + 1]
step.value = tutorialIds.value[i + 1] as number
}
defineExpose({ tutorialIds, prevDisabled, nextDisabled, prev, next })
@@ -44,88 +65,45 @@ async function prepare() {
content.value = "暂无教程"
}
if (!tutorialIds.value.includes(step.value)) {
step.value = tutorialIds.value[0]
step.value = tutorialIds.value[0] as number
}
}
async function getContent() {
async function render() {
const data = await Tutorial.get(step.value)
taskId.value = data.task_ptr
const merged = `# #${data.display} ${data.title}\n${data.content}`
content.value = await marked.parse(merged, { async: true })
}
function addButton() {
const action = document.createElement("div")
action.className = "codeblock-action"
const pres = $content.value?.querySelectorAll("pre") ?? []
for (const pre of pres) {
let timer = 0
let copyTimer = 0
const actions = action.cloneNode() as HTMLDivElement
pre.insertBefore(actions, pre.children[0])
const $code = pre.childNodes[1] as HTMLPreElement
const match = $code.className.match(/-(.*)/)
let lang = "html"
if (match) lang = match[1].toLowerCase()
const langSpan = document.createElement("span")
langSpan.className = "lang"
langSpan.textContent = lang.toUpperCase()
const btnGroup = document.createElement("div")
btnGroup.className = "btn-group"
const copyBtn = document.createElement("button")
copyBtn.className = "action-btn"
copyBtn.textContent = "复制"
const replaceBtn = document.createElement("button")
replaceBtn.className = "action-btn"
replaceBtn.textContent = "替换"
btnGroup.appendChild(copyBtn)
btnGroup.appendChild(replaceBtn)
actions.appendChild(langSpan)
actions.appendChild(btnGroup)
copyBtn.onclick = () => {
const content = pre.children[1].textContent
copyFn(content ?? "")
copyBtn.textContent = "已复制"
clearTimeout(copyTimer)
copyTimer = setTimeout(() => {
copyBtn.textContent = "复制"
function flash(btn: HTMLButtonElement, done: string, original: string) {
btn.textContent = done
setTimeout(() => {
btn.textContent = original
}, 1000)
}
}
replaceBtn.onclick = () => {
function setupCodeActions() {
$content.value?.addEventListener("click", (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(
"[data-action]",
)
if (!btn) return
const wrapper = btn.closest<HTMLElement>("[data-lang]")!
const lang = wrapper.dataset.lang ?? "html"
const code = wrapper.querySelector("code")?.textContent ?? ""
if (btn.dataset.action === "copy") {
copyFn(code)
flash(btn, "已复制", "复制")
} else if (btn.dataset.action === "replace") {
tab.value = lang
const content = pre.children[1].textContent
if (lang === "html") html.value = content
if (lang === "css") css.value = content
if (lang === "js") js.value = content
replaceBtn.textContent = "已替换"
clearTimeout(timer)
timer = setTimeout(() => {
replaceBtn.textContent = "替换"
}, 1000)
if (lang === "html") html.value = code
if (lang === "css") css.value = code
if (lang === "js") js.value = code
flash(btn, "已替换", "替换")
}
}
}
function modifyLink() {
const links = $content.value?.querySelectorAll("a") ?? []
for (const link of links) {
link.target = "_blank"
}
}
async function render() {
await getContent()
addButton()
modifyLink()
})
}
async function init() {
@@ -133,7 +111,10 @@ async function init() {
render()
}
onMounted(init)
onMounted(() => {
setupCodeActions()
init()
})
watch(step, (v) => {
router.push({ name: "home-tutorial", params: { display: v } })
render()
@@ -157,8 +138,24 @@ watch(step, (v) => {
font-family: Monaco;
}
.codeblock-action {
.markdown-body .codeblock-wrapper {
padding: 1rem;
background-color: #f6f8fa;
border-radius: 6px;
margin-bottom: 1rem;
overflow: auto;
}
.markdown-body .codeblock-wrapper pre {
padding: 0;
background-color: transparent;
border-radius: 0;
margin-bottom: 0;
overflow: visible;
}
.codeblock-action {
margin-bottom: 0.5rem;
font-family:
v-sans,
system-ui,

View File

@@ -0,0 +1,188 @@
<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,42 @@
<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,159 @@
<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]
nominate: [row: SubmissionOut]
}>()
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),
],
)
},
},
{
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
? [
{
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,74 @@
<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>

1
src/env.d.ts vendored
View File

@@ -3,6 +3,7 @@ interface ImportMetaEnv {
readonly PUBLIC_BASE_URL: string
readonly PUBLIC_ADMIN_URL: string
readonly PUBLIC_MAXKB_URL: string
readonly PUBLIC_ICONIFY_URL: string
}
interface ImportMeta {

View File

@@ -1,6 +1,7 @@
import { createApp } from "vue"
import { create } from "naive-ui"
import App from "./App.vue"
import { addAPIProvider } from "@iconify/vue"
//@ts-ignore
import "github-markdown-css/github-markdown-light.css"
@@ -15,6 +16,7 @@ import hljs from "highlight.js/lib/core"
import xml from "highlight.js/lib/languages/xml"
import css from "highlight.js/lib/languages/css"
import javascript from "highlight.js/lib/languages/javascript"
import plaintext from "highlight.js/lib/languages/plaintext"
//@ts-ignore
import "highlight.js/styles/github.min.css"
import { router } from "./router"
@@ -22,10 +24,11 @@ import { router } from "./router"
hljs.registerLanguage("html", xml)
hljs.registerLanguage("css", css)
hljs.registerLanguage("js", javascript)
hljs.registerLanguage("javascript", javascript)
hljs.registerLanguage("plaintext", plaintext)
marked.use({
gfm: true,
async: true,
})
marked.use(
markedHighlight({
@@ -47,3 +50,9 @@ const naive = create()
app.use(naive)
app.use(router)
app.mount("#app")
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
addAPIProvider("", {
resources: [import.meta.env.PUBLIC_ICONIFY_URL],
})
}

158
src/pages/ChallengeHome.vue Normal file
View File

@@ -0,0 +1,158 @@
<template>
<n-split :size="leftSize" min="350px" max="700px">
<template #1>
<div class="left-panel">
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
<template #prefix>
<n-button text @click="back" style="margin: 0 8px">
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
</n-button>
</template>
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
<div
class="markdown-body"
style="padding: 12px; overflow-y: auto; height: 100%"
v-html="challengeContent"
/>
</n-tab-pane>
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
<PromptPanel />
</n-tab-pane>
</n-tabs>
</div>
</template>
<template #2>
<div class="right-panel">
<Preview
:html="html"
:css="css"
:js="js"
show-code-button
clearable
@showCode="showCode = true"
@clear="clearAll"
/>
</div>
</template>
</n-split>
<n-modal
v-model:show="showCode"
preset="card"
title="代码"
style="width: 700px"
>
<n-tabs type="line">
<n-tab-pane name="html" tab="HTML">
<n-code :code="html" language="html" />
</n-tab-pane>
<n-tab-pane name="css" tab="CSS">
<n-code :code="css" language="css" />
</n-tab-pane>
<n-tab-pane name="js" tab="JS">
<n-code :code="js" language="javascript" />
</n-tab-pane>
</n-tabs>
</n-modal>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from "vue"
import { useRoute, useRouter } from "vue-router"
import { useMessage } from "naive-ui"
import { Icon } from "@iconify/vue"
import { marked } from "marked"
import PromptPanel from "../components/PromptPanel.vue"
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,
loadHistory,
} from "../store/prompt"
const route = useRoute()
const router = useRouter()
const message = useMessage()
const leftSize = ref(0.4)
const activeTab = ref("desc")
const challengeTitle = ref("")
const challengeContent = ref("")
const showCode = ref(false)
watch(streaming, (val) => {
if (val) activeTab.value = "chat"
})
async function loadChallenge() {
const display = Number(route.params.display)
const data = await Challenge.get(display)
taskId.value = data.task_ptr
challengeTitle.value = `#${data.display} ${data.title}`
challengeContent.value = await marked.parse(data.content, { async: true })
loadHistory(data.task_ptr) // HTTP preload — async, non-blocking
connectPrompt(data.task_ptr) // WebSocket — synchronous open
setOnCodeComplete(async (code) => {
if (!conversationId.value) return
try {
await Submission.create(taskId.value, {
html: code.html ?? "",
css: code.css ?? "",
js: code.js ?? "",
conversationId: conversationId.value,
})
message.success("已自动提交本次对话生成的代码")
} catch {
// 静默失败,不打扰用户
}
})
}
function clearAll() {
html.value = ""
css.value = ""
js.value = ""
}
function back() {
disconnectPrompt()
router.push({ name: "home-challenge-list" })
}
onMounted(loadChallenge)
onUnmounted(disconnectPrompt)
</script>
<style scoped>
.left-panel {
height: 100%;
overflow: hidden;
}
.left-tabs {
height: 100%;
display: flex;
flex-direction: column;
}
.left-tabs :deep(.n-tabs-pane-wrapper) {
flex: 1;
overflow: hidden;
}
.left-tabs :deep(.n-tab-pane) {
height: 100%;
padding: 0;
}
.right-panel {
height: 100%;
display: flex;
flex-direction: column;
}
</style>

View File

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

View File

@@ -1,38 +1,66 @@
<template>
<n-grid class="container" x-gap="10" :cols="3">
<n-gi :span="1">
<n-flex vertical>
<n-flex justify="space-between">
<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; 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">
<div>
<n-select
:value="query.flag"
style="width: 100px"
clearable
placeholder="标记"
:options="flagFilterOptions"
@update:value="handleFlagSelect"
/>
<n-input
style="width: 120px"
v-model:value="query.username"
clearable
/>
</div>
<n-pagination
v-model:page="query.page"
:page-size="10"
:item-count="count"
simple
/>
<n-button
secondary
style="padding: 0 10px"
title="刷新"
@click="init"
>
</n-pagination>
<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>
</n-gi>
<n-gi :span="2">
</template>
<template #2>
<div style="height: 100%; padding-left: 10px">
<Preview
v-if="submission.id"
:html="html"
@@ -42,62 +70,145 @@
@after-score="afterScore"
@show-code="codeModal = true"
/>
</n-gi>
</n-grid>
<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>
</div>
</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-split>
<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 { type DataTableColumn } from "naive-ui"
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
import { NButton, NDataTable, type DataTableColumn } from "naive-ui"
import { Icon } from "@iconify/vue"
import { Submission } from "../api"
import type { SubmissionOut } from "../utils/type"
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, 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 chainConversationId = ref<string | undefined>()
// 展开行
const expandedKeys = ref<string[]>([])
const expandedData = reactive(new Map<string, SubmissionOut[]>())
const expandedLoading = reactive(new Set<string>())
// 管理员判断
const isAdmin = computed(() => roleAdmin.value || roleSuper.value)
// 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
})
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 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),
onNominate: (r) => handleNominateChild(r, row.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: "created",
@@ -113,30 +224,76 @@ const columns: DataTableColumn<SubmissionOut>[] = [
{
title: "任务",
key: "task_title",
render: (submission) => h(TaskTitle, { submission }),
render: (row) => h(TaskTitle, { submission: row }),
},
{
title: "我打的分",
key: "my_score",
render: (row) => {
if (row.my_score > 0) return row.my_score
else return "-"
},
},
{
title: "平均得分",
title: "得分",
key: "score",
width: 70,
render: (row) => {
if (row.score > 0) return row.score.toFixed(2)
else return "-"
const myScore = row.my_score > 0 ? String(row.my_score) : "-"
const avgScore = row.score > 0 ? row.score.toFixed(2) : "-"
return h(
"div",
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
[
h("span", avgScore),
h("span", { style: { fontSize: "11px", color: "#999" } }, myScore),
],
)
},
},
{
title: "次数",
key: "submit_count",
width: 60,
render: (row) => row.submit_count || "-",
},
]
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),
onClick: () => {
getSubmissionByID(row.id)
handleExpand(
expandedKeys.value.includes(row.id)
? expandedKeys.value.filter((k) => k !== row.id)
: [...expandedKeys.value, row.id],
)
},
}
}
@@ -145,6 +302,8 @@ function rowClassName(row: SubmissionOut) {
}
async function init() {
expandedKeys.value = []
expandedData.clear()
const res = await Submission.list(query)
data.value = res.items
count.value = res.count
@@ -154,11 +313,26 @@ async function getSubmissionByID(id: string) {
submission.value = await Submission.get(id)
}
async function handleNominateChild(row: SubmissionOut, parentId: string) {
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) => {
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
})
}
@@ -185,6 +359,14 @@ watchDebounced(
},
{ debounce: 500, maxWait: 1000 },
)
watch(
() => query.flag,
() => {
query.page = 1
init()
},
)
onMounted(init)
onUnmounted(() => {
submission.value = {
@@ -205,11 +387,12 @@ onUnmounted(() => {
}
})
</script>
<style scoped>
.container {
padding: 10px;
box-sizing: border-box;
height: calc(100% - 43px);
width: 100%;
}
:deep(.row-active td) {

View File

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

View File

@@ -9,7 +9,11 @@ const routes = [
{ path: "/tutorial", name: "home-tutorial-list", component: Home },
{ path: "/tutorial/:display", name: "home-tutorial", component: Home },
{ path: "/challenge", name: "home-challenge-list", component: Home },
{ path: "/challenge/:display", name: "home-challenge", component: Home },
{
path: "/challenge/:display",
name: "home-challenge",
component: () => import("./pages/ChallengeHome.vue"),
},
{
path: "/submissions/:page",
name: "submissions",

184
src/store/prompt.ts Normal file
View File

@@ -0,0 +1,184 @@
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"
content: string
code?: { html: string | null; css: string | null; js: string | null }
created?: string
}
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
export function setOnCodeComplete(fn: typeof _onCodeComplete) {
_onCodeComplete = fn
}
let ws: WebSocket | null = null
export function connectPrompt(taskId: number) {
if (ws) ws.close()
ws = new WebSocket(`${WS_BASE_URL}/ws/prompt/${taskId}/`)
ws.onopen = () => {
connected.value = true
}
ws.onmessage = (event) => {
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
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
streamingContent.value += data.content
} else if (data.type === "complete") {
streaming.value = false
// Push the full assistant message
messages.value.push({
role: "assistant",
content: streamingContent.value,
code: data.code,
})
streamingContent.value = ""
// Apply code to editors
if (data.code) {
applyCode(data.code)
if (_onCodeComplete) {
_onCodeComplete(data.code)
}
}
} else if (data.type === "error") {
streaming.value = false
streamingContent.value = ""
messages.value.push({
role: "assistant",
content: data.content,
})
}
}
ws.onclose = () => {
connected.value = false
}
}
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
}
messages.value = []
conversationId.value = ""
connected.value = false
streaming.value = false
streamingContent.value = ""
_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 })
ws.send(JSON.stringify({ type: "message", content }))
}
export function newConversation() {
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: "new_conversation" }))
}
function applyCode(code: {
html: string | null
css: string | null
js: string | null
}) {
if (code.html !== null) html.value = code.html
if (code.css !== null) css.value = code.css
if (code.js !== null) js.value = code.js
}

View File

@@ -39,6 +39,9 @@ export const ADMIN_URL = import.meta.env.PUBLIC_ADMIN_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 enum TASK_TYPE {
Tutorial = "tutorial",
Challenge = "challenge",

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",
@@ -14,6 +24,8 @@ export function getRole(role: Role) {
}[role]
}
export type FlagType = "red" | "blue" | "green" | "yellow" | null
export interface TutorialSlim {
display: number
title: string
@@ -58,11 +70,16 @@ export interface SubmissionOut {
id: string
userid: number
username: string
task_id: number
task_display: number
task_type: TASK_TYPE
task_title: string
score: number
my_score: number
conversation_id?: string
flag?: FlagType
nominated: boolean
submit_count: number
created: Date
modified: Date
}
@@ -77,9 +94,60 @@ export interface SubmissionAll {
task_title: string
score: number
my_score: number
flag?: FlagType
html: ""
css: ""
js: ""
created: 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,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
"include": ["src/**/*.ts", "src/**/*.vue"]
}