Compare commits
27 Commits
7b7f6ea81d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a7cfa66952 | |||
| 0636ad6f57 | |||
| e0f1cdb337 | |||
| 88d6ffaf53 | |||
| 83cd62a110 | |||
| 4e95a2fad0 | |||
| dd52e3e1f9 | |||
| 3c6b616d81 | |||
| 0dd5cbeee9 | |||
| d68ef60ab9 | |||
| 98d8099b5d | |||
| 040e4e3253 | |||
| 7e65340f63 | |||
| 2bd9382c8c | |||
| a7aa4f63ac | |||
| 9c577f9bc1 | |||
| 016e070fb9 | |||
| 0ee8b0d6ea | |||
| 334b2d77b1 | |||
| b4bfc7706c | |||
| f7e9d39bc2 | |||
| 46347ff99b | |||
| 65022968a5 | |||
| 04bb023c2e | |||
| 725fad4a55 | |||
| 4774c05809 | |||
| 33d75bf83a |
@@ -1,4 +1,5 @@
|
|||||||
PUBLIC_WEB_URL=https://web.xuyue.cc
|
PUBLIC_WEB_URL=https://web.xuyue.cc
|
||||||
PUBLIC_ADMIN_URL=https://web.xuyue.cc/admin
|
PUBLIC_ADMIN_URL=https://web.xuyue.cc/admin
|
||||||
PUBLIC_BASE_URL=https://web.xuyue.cc/api
|
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_MAXKB_URL=https://maxkb.xuyue.cc/chat/api/embed?protocol=https&host=maxkb.xuyue.cc&token=df542e305f27dee6
|
||||||
|
PUBLIC_ICONIFY_URL=https://icon.xuyue.cc
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
components.d.ts
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
53
components.d.ts
vendored
53
components.d.ts
vendored
@@ -1,53 +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']
|
|
||||||
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
|
||||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
|
||||||
NDropdown: typeof import('naive-ui')['NDropdown']
|
|
||||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
|
||||||
NFlex: typeof import('naive-ui')['NFlex']
|
|
||||||
NForm: typeof import('naive-ui')['NForm']
|
|
||||||
NFormItem: typeof import('naive-ui')['NFormItem']
|
|
||||||
NGi: typeof import('naive-ui')['NGi']
|
|
||||||
NGrid: typeof import('naive-ui')['NGrid']
|
|
||||||
NInput: typeof import('naive-ui')['NInput']
|
|
||||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
|
||||||
NModal: typeof import('naive-ui')['NModal']
|
|
||||||
NModalProvider: typeof import('naive-ui')['NModalProvider']
|
|
||||||
NPopover: typeof import('naive-ui')['NPopover']
|
|
||||||
NRate: typeof import('naive-ui')['NRate']
|
|
||||||
NSplit: typeof import('naive-ui')['NSplit']
|
|
||||||
NTab: typeof import('naive-ui')['NTab']
|
|
||||||
NTabPane: typeof import('naive-ui')['NTabPane']
|
|
||||||
NTabs: typeof import('naive-ui')['NTabs']
|
|
||||||
NTag: typeof import('naive-ui')['NTag']
|
|
||||||
NText: typeof import('naive-ui')['NText']
|
|
||||||
Preview: typeof import('./src/components/Preview.vue')['default']
|
|
||||||
PromptPanel: typeof import('./src/components/PromptPanel.vue')['default']
|
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
|
||||||
Task: typeof import('./src/components/Task.vue')['default']
|
|
||||||
TaskTitle: typeof import('./src/components/submissions/TaskTitle.vue')['default']
|
|
||||||
Toolbar: typeof import('./src/components/Toolbar.vue')['default']
|
|
||||||
Tutorial: typeof import('./src/components/Tutorial.vue')['default']
|
|
||||||
UserActions: typeof import('./src/components/dashboard/UserActions.vue')['default']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
83
public/tailwindcss.min.js
vendored
Normal file
83
public/tailwindcss.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
src/App.vue
12
src/App.vue
@@ -8,10 +8,14 @@ import { STORAGE_KEY } from "./utils/const"
|
|||||||
import hljs from "highlight.js/lib/core"
|
import hljs from "highlight.js/lib/core"
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const data = await Account.getMyProfile()
|
try {
|
||||||
user.loaded = true
|
const data = await Account.getMyProfile()
|
||||||
user.username = data.username
|
user.loaded = true
|
||||||
user.role = data.role
|
user.username = data.username
|
||||||
|
user.role = data.role
|
||||||
|
} catch {
|
||||||
|
user.loaded = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(authed, (v) => {
|
watch(authed, (v) => {
|
||||||
|
|||||||
77
src/api.ts
77
src/api.ts
@@ -1,6 +1,13 @@
|
|||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import { router } from "./router"
|
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"
|
import { BASE_URL, STORAGE_KEY } from "./utils/const"
|
||||||
|
|
||||||
const http = axios.create({
|
const http = axios.create({
|
||||||
@@ -67,6 +74,18 @@ export const Account = {
|
|||||||
const res = await http.post("/account/batch", payload)
|
const res = await http.post("/account/batch", payload)
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async 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 = {
|
export const Tutorial = {
|
||||||
@@ -151,22 +170,70 @@ export const Submission = {
|
|||||||
return res.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", {
|
const res = await http.get("/submission", {
|
||||||
params: query,
|
params: query,
|
||||||
})
|
})
|
||||||
return res.data
|
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) {
|
async get(id: string) {
|
||||||
const res = await http.get("/submission/" + id)
|
const res = await http.get("/submission/" + id)
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async delete(id: string) {
|
||||||
|
const res = await http.delete("/submission/" + id)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
|
||||||
async updateScore(id: string, score: number) {
|
async updateScore(id: string, score: number) {
|
||||||
const res = await http.put(`/submission/${id}/score`, { score })
|
const res = await http.put(`/submission/${id}/score`, { score })
|
||||||
return res.data
|
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 = {
|
export const Prompt = {
|
||||||
@@ -177,9 +244,11 @@ export const Prompt = {
|
|||||||
return (await http.get("/prompt/conversations/", { params })).data
|
return (await http.get("/prompt/conversations/", { params })).data
|
||||||
},
|
},
|
||||||
|
|
||||||
async getMessages(conversationId: string) {
|
async getMessages(conversationId: string): Promise<PromptMessage[]> {
|
||||||
return (
|
return (
|
||||||
await http.get(`/prompt/conversations/${conversationId}/messages/`)
|
await http.get<PromptMessage[]>(
|
||||||
|
`/prompt/conversations/${conversationId}/messages/`,
|
||||||
|
)
|
||||||
).data
|
).data
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<span class="label">预加载</span>
|
<span class="label">预加载</span>
|
||||||
<n-tag type="success">Normalize.css</n-tag>
|
<n-tag type="success">Normalize.css</n-tag>
|
||||||
<n-tag type="success">jQuery</n-tag>
|
<n-tag type="success">Tailwind CSS</n-tag>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
@@ -86,6 +86,7 @@ import Editor from "./Editor.vue"
|
|||||||
import Toolbar from "./Toolbar.vue"
|
import Toolbar from "./Toolbar.vue"
|
||||||
import { html, css, js, tab, size, reset } from "../store/editors"
|
import { html, css, js, tab, size, reset } from "../store/editors"
|
||||||
import { taskId } from "../store/task"
|
import { taskId } from "../store/task"
|
||||||
|
import { conversationId } from "../store/prompt"
|
||||||
import { Submission } from "../api"
|
import { Submission } from "../api"
|
||||||
import { NCode, useDialog, useMessage } from "naive-ui"
|
import { NCode, useDialog, useMessage } from "naive-ui"
|
||||||
import { h, ref } from "vue"
|
import { h, ref } from "vue"
|
||||||
@@ -145,6 +146,7 @@ async function doSubmit() {
|
|||||||
html: html.value,
|
html: html.value,
|
||||||
css: css.value,
|
css: css.value,
|
||||||
js: js.value,
|
js: js.value,
|
||||||
|
conversationId: conversationId.value || undefined,
|
||||||
})
|
})
|
||||||
message.success("提交成功")
|
message.success("提交成功")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,62 +2,219 @@
|
|||||||
<n-modal
|
<n-modal
|
||||||
preset="card"
|
preset="card"
|
||||||
title="登录"
|
title="登录"
|
||||||
style="width: 400px"
|
style="width: 420px"
|
||||||
v-model:show="loginModal"
|
v-model:show="loginModal"
|
||||||
>
|
>
|
||||||
<n-form>
|
<n-tabs v-model:value="activeTab" @update:value="onTabChange">
|
||||||
<n-form-item label="用户名">
|
<n-tab-pane name="student" tab="学生登录">
|
||||||
<n-input v-model:value="name" name="username"></n-input>
|
<n-form>
|
||||||
</n-form-item>
|
<n-form-item label="班级">
|
||||||
<n-form-item label="密码">
|
<n-select
|
||||||
<n-input
|
v-model:value="selectedClass"
|
||||||
type="password"
|
:options="classOptions"
|
||||||
v-model:value="password"
|
placeholder="选择班级"
|
||||||
name="password"
|
:loading="classesLoading"
|
||||||
></n-input>
|
@update:value="onClassChange"
|
||||||
</n-form-item>
|
/>
|
||||||
<n-alert
|
</n-form-item>
|
||||||
type="error"
|
<n-form-item label="姓名">
|
||||||
v-if="showMessage"
|
<n-select
|
||||||
class="message"
|
v-model:value="selectedUsername"
|
||||||
title="登录失败,请检查用户名和密码"
|
:options="nameOptions"
|
||||||
></n-alert>
|
placeholder="选择姓名"
|
||||||
<n-flex>
|
:loading="namesLoading"
|
||||||
<n-button block :loading="loading" @click="submit" type="primary"
|
:disabled="!selectedClass || namesLoading"
|
||||||
>登录</n-button
|
/>
|
||||||
>
|
</n-form-item>
|
||||||
</n-flex>
|
<n-form-item label="密码">
|
||||||
</n-form>
|
<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>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from "vue"
|
import { ref, computed, onMounted } from "vue"
|
||||||
import { Account } from "../api"
|
import { Account } from "../api"
|
||||||
import { loginModal } from "../store/modal"
|
import { loginModal } from "../store/modal"
|
||||||
import { user } from "../store/user"
|
import { user } from "../store/user"
|
||||||
|
|
||||||
const name = ref("")
|
// Tab state
|
||||||
const password = ref("")
|
const activeTab = ref("student")
|
||||||
const loading = ref(false)
|
|
||||||
const showMessage = ref(false)
|
|
||||||
|
|
||||||
async function submit() {
|
// Student tab state
|
||||||
loading.value = true
|
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 {
|
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.username = data.username
|
||||||
user.role = data.role
|
user.role = data.role
|
||||||
user.loaded = true
|
user.loaded = true
|
||||||
loginModal.value = false
|
loginModal.value = false
|
||||||
loading.value = false
|
} catch {
|
||||||
} catch (err) {
|
showStudentError.value = true
|
||||||
showMessage.value = true
|
} finally {
|
||||||
loading.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.message {
|
.message {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,14 +4,21 @@
|
|||||||
<n-flex>
|
<n-flex>
|
||||||
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
|
<n-button quaternary @click="download" :disabled="!showDL">下载</n-button>
|
||||||
<n-button quaternary @click="open">全屏</n-button>
|
<n-button quaternary @click="open">全屏</n-button>
|
||||||
|
<n-button quaternary v-if="props.clearable" @click="clear">清空</n-button>
|
||||||
|
<n-button
|
||||||
|
quaternary
|
||||||
|
v-if="props.showCodeButton"
|
||||||
|
@click="emits('showCode')"
|
||||||
|
>代码</n-button
|
||||||
|
>
|
||||||
<n-button quaternary v-if="props.submissionId" @click="copyLink">
|
<n-button quaternary v-if="props.submissionId" @click="copyLink">
|
||||||
复制链接
|
链接
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-flex v-if="!!submission.id">
|
<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">
|
<n-popover v-if="submission.my_score === 0">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-button secondary type="primary">手动打分</n-button>
|
<n-button secondary type="primary">打分</n-button>
|
||||||
</template>
|
</template>
|
||||||
<n-rate :size="30" @update:value="updateScore" />
|
<n-rate :size="30" @update:value="updateScore" />
|
||||||
</n-popover>
|
</n-popover>
|
||||||
@@ -35,10 +42,12 @@ interface Props {
|
|||||||
css: string
|
css: string
|
||||||
js: string
|
js: string
|
||||||
submissionId?: string
|
submissionId?: string
|
||||||
|
showCodeButton?: boolean
|
||||||
|
clearable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emits = defineEmits(["afterScore", "showCode"])
|
const emits = defineEmits(["afterScore", "showCode", "clear"])
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -55,11 +64,11 @@ function getContent() {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<style>${props.css}</style>
|
<style>${props.css}</style>
|
||||||
<link rel="stylesheet" href="/normalize.min.css" />
|
<link rel="stylesheet" href="/normalize.min.css" />
|
||||||
<script src="/jquery.min.js"><\/script>
|
<script src="/tailwindcss.min.js"><\/script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
${props.html}
|
${props.html}
|
||||||
<script type="module">${props.js}<\/script>
|
<script>${props.js}<\/script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
}
|
}
|
||||||
@@ -101,6 +110,10 @@ function open() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
emits("clear")
|
||||||
|
}
|
||||||
|
|
||||||
function copyLink() {
|
function copyLink() {
|
||||||
copy(`${document.location.origin}/submission/${props.submissionId}`)
|
copy(`${document.location.origin}/submission/${props.submissionId}`)
|
||||||
message.success("该提交的链接已复制")
|
message.success("该提交的链接已复制")
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="prompt-panel">
|
<div class="prompt-panel">
|
||||||
<div class="messages" ref="messagesRef">
|
<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 v-for="(msg, i) in messages" :key="i" :class="['message', msg.role]">
|
||||||
<div class="message-role">{{ msg.role === 'user' ? '我' : 'AI' }}</div>
|
<div class="message-role">{{ msg.role === "user" ? "我" : "AI" }}</div>
|
||||||
<div class="message-content" v-html="renderContent(msg)"></div>
|
<div class="message-content" v-html="renderContent(msg)"></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="streaming" class="message assistant">
|
<div v-if="streaming" class="message assistant">
|
||||||
<div class="message-role">AI</div>
|
<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>
|
</div>
|
||||||
<div class="input-area">
|
<div class="input-area">
|
||||||
@@ -20,7 +32,12 @@
|
|||||||
@keydown.enter.exact.prevent="send"
|
@keydown.enter.exact.prevent="send"
|
||||||
/>
|
/>
|
||||||
<n-flex justify="space-between" align="center" style="margin-top: 8px">
|
<n-flex justify="space-between" align="center" style="margin-top: 8px">
|
||||||
<n-button text size="small" @click="newConversation" :disabled="streaming">
|
<n-button
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
@click="newConversation"
|
||||||
|
:disabled="streaming"
|
||||||
|
>
|
||||||
新对话
|
新对话
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button
|
<n-button
|
||||||
@@ -38,13 +55,14 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick } from "vue"
|
import { ref, watch, nextTick } from "vue"
|
||||||
import { marked } from "marked"
|
import { marked, Renderer } from "marked"
|
||||||
import {
|
import {
|
||||||
messages,
|
messages,
|
||||||
streaming,
|
streaming,
|
||||||
streamingContent,
|
streamingContent,
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
newConversation,
|
newConversation,
|
||||||
|
historyLoading,
|
||||||
} from "../store/prompt"
|
} from "../store/prompt"
|
||||||
|
|
||||||
const input = ref("")
|
const input = ref("")
|
||||||
@@ -57,8 +75,54 @@ function send() {
|
|||||||
input.value = ""
|
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 {
|
function renderMarkdown(text: string): string {
|
||||||
return marked.parse(text) as string
|
return marked.parse(text, { renderer }) as string
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderContent(msg: { role: string; content: string }): string {
|
function renderContent(msg: { role: string; content: string }): string {
|
||||||
@@ -66,16 +130,13 @@ function renderContent(msg: { role: string; content: string }): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll to bottom on new messages
|
// Auto-scroll to bottom on new messages
|
||||||
watch(
|
watch([() => messages.value.length, streamingContent], () => {
|
||||||
[() => messages.value.length, streamingContent],
|
nextTick(() => {
|
||||||
() => {
|
if (messagesRef.value) {
|
||||||
nextTick(() => {
|
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||||
if (messagesRef.value) {
|
}
|
||||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
})
|
||||||
}
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -124,8 +185,112 @@ watch(
|
|||||||
font-size: 13px;
|
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 {
|
.input-area {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-top: 1px solid #e0e0e0;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -38,7 +38,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-flex>
|
<n-flex>
|
||||||
|
<n-button v-if="roleSuper" text @click="statsModal = true">
|
||||||
|
<Icon :width="16" icon="lucide:bar-chart-2"></Icon>
|
||||||
|
</n-button>
|
||||||
<n-button
|
<n-button
|
||||||
|
v-if="authed"
|
||||||
text
|
text
|
||||||
@click="$router.push({ name: 'submissions', params: { page: 1 } })"
|
@click="$router.push({ name: 'submissions', params: { page: 1 } })"
|
||||||
>
|
>
|
||||||
@@ -55,21 +59,24 @@
|
|||||||
<Tutorial v-if="taskTab === TASK_TYPE.Tutorial" ref="tutorialRef" />
|
<Tutorial v-if="taskTab === TASK_TYPE.Tutorial" ref="tutorialRef" />
|
||||||
<Challenge v-else />
|
<Challenge v-else />
|
||||||
</div>
|
</div>
|
||||||
|
<TaskStatsModal v-model:show="statsModal" :task-id="taskId" />
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { computed, onMounted, ref } from "vue"
|
import { computed, onMounted, ref } from "vue"
|
||||||
import { step } from "../store/tutorial"
|
import { step } from "../store/tutorial"
|
||||||
import { roleSuper } from "../store/user"
|
import { authed, roleAdmin, roleSuper } from "../store/user"
|
||||||
import { taskTab, challengeDisplay } from "../store/task"
|
import { taskTab, taskId, challengeDisplay } from "../store/task"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { TASK_TYPE } from "../utils/const"
|
import { TASK_TYPE } from "../utils/const"
|
||||||
import Challenge from "./Challenge.vue"
|
import Challenge from "./Challenge.vue"
|
||||||
import Tutorial from "./Tutorial.vue"
|
import Tutorial from "./Tutorial.vue"
|
||||||
|
import TaskStatsModal from "./TaskStatsModal.vue"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const tutorialRef = ref<InstanceType<typeof Tutorial>>()
|
const tutorialRef = ref<InstanceType<typeof Tutorial>>()
|
||||||
|
const statsModal = ref(false)
|
||||||
|
|
||||||
defineEmits(["hide"])
|
defineEmits(["hide"])
|
||||||
|
|
||||||
|
|||||||
560
src/components/TaskStatsModal.vue
Normal file
560
src/components/TaskStatsModal.vue
Normal 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>
|
||||||
@@ -57,6 +57,7 @@ const menu = computed(() => [
|
|||||||
icon: () =>
|
icon: () =>
|
||||||
h(Icon, {
|
h(Icon, {
|
||||||
icon: "streamline-emojis:robot-face-1",
|
icon: "streamline-emojis:robot-face-1",
|
||||||
|
width: 20,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -66,6 +67,7 @@ const menu = computed(() => [
|
|||||||
icon: () =>
|
icon: () =>
|
||||||
h(Icon, {
|
h(Icon, {
|
||||||
icon: "skill-icons:django",
|
icon: "skill-icons:django",
|
||||||
|
width: 20,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -74,6 +76,16 @@ const menu = computed(() => [
|
|||||||
icon: () =>
|
icon: () =>
|
||||||
h(Icon, {
|
h(Icon, {
|
||||||
icon: "streamline-emojis:bar-chart",
|
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: () =>
|
icon: () =>
|
||||||
h(Icon, {
|
h(Icon, {
|
||||||
icon: "streamline-emojis:hot-beverage-2",
|
icon: "streamline-emojis:hot-beverage-2",
|
||||||
|
width: 20,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -106,6 +119,9 @@ function clickMenu(name: string) {
|
|||||||
query: { username: user.username },
|
query: { username: user.username },
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
case "ranking":
|
||||||
|
router.push({ name: "ranking" })
|
||||||
|
break
|
||||||
case "logout":
|
case "logout":
|
||||||
handleLogout()
|
handleLogout()
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -11,6 +11,27 @@ import { step } from "../store/tutorial"
|
|||||||
import { taskId } from "../store/task"
|
import { taskId } from "../store/task"
|
||||||
import { useRouter } from "vue-router"
|
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 router = useRouter()
|
||||||
const tutorialIds = ref<number[]>([])
|
const tutorialIds = ref<number[]>([])
|
||||||
const content = ref("")
|
const content = ref("")
|
||||||
@@ -28,12 +49,12 @@ const nextDisabled = () => {
|
|||||||
|
|
||||||
function prev() {
|
function prev() {
|
||||||
const i = tutorialIds.value.indexOf(step.value)
|
const i = tutorialIds.value.indexOf(step.value)
|
||||||
step.value = tutorialIds.value[i - 1]
|
step.value = tutorialIds.value[i - 1] as number
|
||||||
}
|
}
|
||||||
|
|
||||||
function next() {
|
function next() {
|
||||||
const i = tutorialIds.value.indexOf(step.value)
|
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 })
|
defineExpose({ tutorialIds, prevDisabled, nextDisabled, prev, next })
|
||||||
@@ -44,88 +65,45 @@ async function prepare() {
|
|||||||
content.value = "暂无教程"
|
content.value = "暂无教程"
|
||||||
}
|
}
|
||||||
if (!tutorialIds.value.includes(step.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)
|
const data = await Tutorial.get(step.value)
|
||||||
taskId.value = data.task_ptr
|
taskId.value = data.task_ptr
|
||||||
const merged = `# #${data.display} ${data.title}\n${data.content}`
|
const merged = `# #${data.display} ${data.title}\n${data.content}`
|
||||||
content.value = await marked.parse(merged, { async: true })
|
content.value = await marked.parse(merged, { async: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
function addButton() {
|
function flash(btn: HTMLButtonElement, done: string, original: string) {
|
||||||
const action = document.createElement("div")
|
btn.textContent = done
|
||||||
action.className = "codeblock-action"
|
setTimeout(() => {
|
||||||
const pres = $content.value?.querySelectorAll("pre") ?? []
|
btn.textContent = original
|
||||||
for (const pre of pres) {
|
}, 1000)
|
||||||
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")
|
function setupCodeActions() {
|
||||||
langSpan.className = "lang"
|
$content.value?.addEventListener("click", (e: MouseEvent) => {
|
||||||
langSpan.textContent = lang.toUpperCase()
|
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 ?? ""
|
||||||
|
|
||||||
const btnGroup = document.createElement("div")
|
if (btn.dataset.action === "copy") {
|
||||||
btnGroup.className = "btn-group"
|
copyFn(code)
|
||||||
|
flash(btn, "已复制", "复制")
|
||||||
const copyBtn = document.createElement("button")
|
} else if (btn.dataset.action === "replace") {
|
||||||
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 = "复制"
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
replaceBtn.onclick = () => {
|
|
||||||
tab.value = lang
|
tab.value = lang
|
||||||
const content = pre.children[1].textContent
|
if (lang === "html") html.value = code
|
||||||
if (lang === "html") html.value = content
|
if (lang === "css") css.value = code
|
||||||
if (lang === "css") css.value = content
|
if (lang === "js") js.value = code
|
||||||
if (lang === "js") js.value = content
|
flash(btn, "已替换", "替换")
|
||||||
replaceBtn.textContent = "已替换"
|
|
||||||
clearTimeout(timer)
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
replaceBtn.textContent = "替换"
|
|
||||||
}, 1000)
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
async function init() {
|
||||||
@@ -133,7 +111,10 @@ async function init() {
|
|||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(init)
|
onMounted(() => {
|
||||||
|
setupCodeActions()
|
||||||
|
init()
|
||||||
|
})
|
||||||
watch(step, (v) => {
|
watch(step, (v) => {
|
||||||
router.push({ name: "home-tutorial", params: { display: v } })
|
router.push({ name: "home-tutorial", params: { display: v } })
|
||||||
render()
|
render()
|
||||||
@@ -157,8 +138,24 @@ watch(step, (v) => {
|
|||||||
font-family: Monaco;
|
font-family: Monaco;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codeblock-action {
|
.markdown-body .codeblock-wrapper {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
border-radius: 6px;
|
||||||
margin-bottom: 1rem;
|
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:
|
font-family:
|
||||||
v-sans,
|
v-sans,
|
||||||
system-ui,
|
system-ui,
|
||||||
|
|||||||
188
src/components/submissions/ChainModal.vue
Normal file
188
src/components/submissions/ChainModal.vue
Normal 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>
|
||||||
42
src/components/submissions/CodeModal.vue
Normal file
42
src/components/submissions/CodeModal.vue
Normal 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>
|
||||||
159
src/components/submissions/ExpandedSubTable.vue
Normal file
159
src/components/submissions/ExpandedSubTable.vue
Normal 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>
|
||||||
74
src/components/submissions/FlagCell.vue
Normal file
74
src/components/submissions/FlagCell.vue
Normal 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>
|
||||||
@@ -16,6 +16,7 @@ import hljs from "highlight.js/lib/core"
|
|||||||
import xml from "highlight.js/lib/languages/xml"
|
import xml from "highlight.js/lib/languages/xml"
|
||||||
import css from "highlight.js/lib/languages/css"
|
import css from "highlight.js/lib/languages/css"
|
||||||
import javascript from "highlight.js/lib/languages/javascript"
|
import javascript from "highlight.js/lib/languages/javascript"
|
||||||
|
import plaintext from "highlight.js/lib/languages/plaintext"
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
import "highlight.js/styles/github.min.css"
|
import "highlight.js/styles/github.min.css"
|
||||||
import { router } from "./router"
|
import { router } from "./router"
|
||||||
@@ -23,6 +24,8 @@ import { router } from "./router"
|
|||||||
hljs.registerLanguage("html", xml)
|
hljs.registerLanguage("html", xml)
|
||||||
hljs.registerLanguage("css", css)
|
hljs.registerLanguage("css", css)
|
||||||
hljs.registerLanguage("js", javascript)
|
hljs.registerLanguage("js", javascript)
|
||||||
|
hljs.registerLanguage("javascript", javascript)
|
||||||
|
hljs.registerLanguage("plaintext", plaintext)
|
||||||
|
|
||||||
marked.use({
|
marked.use({
|
||||||
gfm: true,
|
gfm: true,
|
||||||
|
|||||||
@@ -9,7 +9,11 @@
|
|||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
|
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
|
||||||
<div class="markdown-body" style="padding: 12px; overflow-y: auto; height: 100%" v-html="challengeContent" />
|
<div
|
||||||
|
class="markdown-body"
|
||||||
|
style="padding: 12px; overflow-y: auto; height: 100%"
|
||||||
|
v-html="challengeContent"
|
||||||
|
/>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
|
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
|
||||||
<PromptPanel />
|
<PromptPanel />
|
||||||
@@ -19,22 +23,24 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #2>
|
<template #2>
|
||||||
<div class="right-panel">
|
<div class="right-panel">
|
||||||
<Preview :html="html" :css="css" :js="js" />
|
<Preview
|
||||||
<n-flex class="toolbar" align="center" :size="8">
|
:html="html"
|
||||||
<n-button secondary @click="showCode = true">查看代码</n-button>
|
:css="css"
|
||||||
<n-button
|
:js="js"
|
||||||
type="primary"
|
show-code-button
|
||||||
:disabled="!conversationId"
|
clearable
|
||||||
:loading="submitLoading"
|
@showCode="showCode = true"
|
||||||
@click="submit"
|
@clear="clearAll"
|
||||||
>
|
/>
|
||||||
提交作品
|
|
||||||
</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n-split>
|
</n-split>
|
||||||
<n-modal v-model:show="showCode" preset="card" title="代码" style="width: 700px">
|
<n-modal
|
||||||
|
v-model:show="showCode"
|
||||||
|
preset="card"
|
||||||
|
title="代码"
|
||||||
|
style="width: 700px"
|
||||||
|
>
|
||||||
<n-tabs type="line">
|
<n-tabs type="line">
|
||||||
<n-tab-pane name="html" tab="HTML">
|
<n-tab-pane name="html" tab="HTML">
|
||||||
<n-code :code="html" language="html" />
|
<n-code :code="html" language="html" />
|
||||||
@@ -60,7 +66,14 @@ import Preview from "../components/Preview.vue"
|
|||||||
import { Challenge, Submission } from "../api"
|
import { Challenge, Submission } from "../api"
|
||||||
import { html, css, js } from "../store/editors"
|
import { html, css, js } from "../store/editors"
|
||||||
import { taskId } from "../store/task"
|
import { taskId } from "../store/task"
|
||||||
import { connectPrompt, disconnectPrompt, conversationId, streaming } from "../store/prompt"
|
import {
|
||||||
|
connectPrompt,
|
||||||
|
disconnectPrompt,
|
||||||
|
conversationId,
|
||||||
|
streaming,
|
||||||
|
setOnCodeComplete,
|
||||||
|
loadHistory,
|
||||||
|
} from "../store/prompt"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -71,7 +84,6 @@ const activeTab = ref("desc")
|
|||||||
const challengeTitle = ref("")
|
const challengeTitle = ref("")
|
||||||
const challengeContent = ref("")
|
const challengeContent = ref("")
|
||||||
const showCode = ref(false)
|
const showCode = ref(false)
|
||||||
const submitLoading = ref(false)
|
|
||||||
|
|
||||||
watch(streaming, (val) => {
|
watch(streaming, (val) => {
|
||||||
if (val) activeTab.value = "chat"
|
if (val) activeTab.value = "chat"
|
||||||
@@ -83,7 +95,28 @@ async function loadChallenge() {
|
|||||||
taskId.value = data.task_ptr
|
taskId.value = data.task_ptr
|
||||||
challengeTitle.value = `#${data.display} ${data.title}`
|
challengeTitle.value = `#${data.display} ${data.title}`
|
||||||
challengeContent.value = await marked.parse(data.content, { async: true })
|
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 {
|
||||||
|
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() {
|
function back() {
|
||||||
@@ -91,24 +124,6 @@ function back() {
|
|||||||
router.push({ name: "home-challenge-list" })
|
router.push({ name: "home-challenge-list" })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (!conversationId.value) return
|
|
||||||
submitLoading.value = true
|
|
||||||
try {
|
|
||||||
await Submission.create(taskId.value, {
|
|
||||||
html: html.value,
|
|
||||||
css: css.value,
|
|
||||||
js: js.value,
|
|
||||||
conversationId: conversationId.value,
|
|
||||||
})
|
|
||||||
message.success("提交成功")
|
|
||||||
} catch {
|
|
||||||
message.error("提交失败")
|
|
||||||
} finally {
|
|
||||||
submitLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadChallenge)
|
onMounted(loadChallenge)
|
||||||
onUnmounted(disconnectPrompt)
|
onUnmounted(disconnectPrompt)
|
||||||
</script>
|
</script>
|
||||||
@@ -140,10 +155,4 @@ onUnmounted(disconnectPrompt)
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ async function init() {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<style>${submission.css}</style>
|
<style>${submission.css}</style>
|
||||||
<link rel="stylesheet" href="/normalize.min.css" />
|
<link rel="stylesheet" href="/normalize.min.css" />
|
||||||
<script src="/jquery.min.js"><\/script>
|
<script src="/tailwindcss.min.js"><\/script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
${submission.html}
|
${submission.html}
|
||||||
<script type="module">${submission.js}<\/script>
|
<script>${submission.js}<\/script>
|
||||||
</body>
|
</body>
|
||||||
</html>`)
|
</html>`)
|
||||||
doc.close()
|
doc.close()
|
||||||
@@ -43,6 +43,8 @@ onMounted(init)
|
|||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.iframe {
|
.iframe {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -1,132 +1,214 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-grid class="container" x-gap="10" :cols="2">
|
<n-split
|
||||||
<n-gi :span="1">
|
class="container"
|
||||||
<n-flex vertical>
|
direction="horizontal"
|
||||||
<n-flex justify="space-between">
|
: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 secondary @click="() => goHome($router, taskTab, step)">
|
||||||
返回首页
|
返回首页
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<div>
|
<n-select
|
||||||
<n-input
|
:value="query.flag"
|
||||||
style="width: 120px"
|
style="width: 100px"
|
||||||
v-model:value="query.username"
|
clearable
|
||||||
clearable
|
placeholder="标记"
|
||||||
/>
|
:options="flagFilterOptions"
|
||||||
</div>
|
@update:value="handleFlagSelect"
|
||||||
|
/>
|
||||||
|
<n-input
|
||||||
|
style="width: 120px"
|
||||||
|
v-model:value="query.username"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
<n-pagination
|
<n-pagination
|
||||||
v-model:page="query.page"
|
v-model:page="query.page"
|
||||||
:page-size="10"
|
:page-size="10"
|
||||||
:item-count="count"
|
:item-count="count"
|
||||||
simple
|
simple
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
secondary
|
||||||
|
style="padding: 0 10px"
|
||||||
|
title="刷新"
|
||||||
|
@click="init"
|
||||||
>
|
>
|
||||||
</n-pagination>
|
<Icon :width="16" icon="lucide:refresh-cw" />
|
||||||
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-data-table
|
<n-data-table
|
||||||
|
flex-height
|
||||||
striped
|
striped
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="data"
|
:data="data"
|
||||||
|
:row-key="(row: SubmissionOut) => row.id"
|
||||||
|
:expanded-row-keys="expandedKeys"
|
||||||
|
@update:expanded-row-keys="handleExpand"
|
||||||
:row-props="rowProps"
|
:row-props="rowProps"
|
||||||
:row-class-name="rowClassName"
|
:row-class-name="rowClassName"
|
||||||
></n-data-table>
|
style="flex: 1; min-height: 0"
|
||||||
</n-flex>
|
/>
|
||||||
</n-gi>
|
|
||||||
<n-gi :span="1">
|
|
||||||
<Preview
|
|
||||||
v-if="submission.id"
|
|
||||||
:html="html"
|
|
||||||
:css="css"
|
|
||||||
:js="js"
|
|
||||||
:submission-id="submission.id"
|
|
||||||
@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>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
<n-tabs animated type="segment">
|
<template #2>
|
||||||
<n-tab-pane name="html" tab="html">
|
<div style="height: 100%; padding-left: 10px">
|
||||||
<n-code :code="html" language="html" word-wrap></n-code>
|
<Preview
|
||||||
</n-tab-pane>
|
v-if="submission.id"
|
||||||
<n-tab-pane name="css" tab="css">
|
:html="html"
|
||||||
<n-code :code="css" language="css" word-wrap></n-code>
|
:css="css"
|
||||||
</n-tab-pane>
|
:js="js"
|
||||||
<n-tab-pane v-if="!!js" name="js" tab="js">
|
:submission-id="submission.id"
|
||||||
<n-code :code="js" language="js" word-wrap></n-code>
|
@after-score="afterScore"
|
||||||
</n-tab-pane>
|
@show-code="codeModal = true"
|
||||||
</n-tabs>
|
/>
|
||||||
</n-modal>
|
|
||||||
<n-modal v-model:show="chainModal" preset="card" title="Prompt 思维链" style="max-width: 60%; max-height: 80vh">
|
|
||||||
<n-spin :show="chainLoading">
|
|
||||||
<div v-for="msg in chainMessages" :key="msg.id" style="margin-bottom: 16px">
|
|
||||||
<div :style="{ fontWeight: 'bold', fontSize: '12px', marginBottom: '4px', color: msg.role === 'user' ? '#2080f0' : '#18a058' }">
|
|
||||||
{{ msg.role === "user" ? "学生" : "AI" }}
|
|
||||||
</div>
|
|
||||||
<div v-html="renderMarkdown(msg.content)" style="font-size: 14px; line-height: 1.6" />
|
|
||||||
</div>
|
</div>
|
||||||
<n-empty v-if="!chainLoading && chainMessages.length === 0" description="暂无对话记录" />
|
</template>
|
||||||
</n-spin>
|
</n-split>
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton, type DataTableColumn } from "naive-ui"
|
|
||||||
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
||||||
import { marked } from "marked"
|
import { NButton, NDataTable, type DataTableColumn } from "naive-ui"
|
||||||
import { Submission, Prompt } from "../api"
|
import { Icon } from "@iconify/vue"
|
||||||
import type { SubmissionOut } from "../utils/type"
|
import { Submission } from "../api"
|
||||||
|
import type { SubmissionOut, FlagType } from "../utils/type"
|
||||||
import { parseTime } from "../utils/helper"
|
import { parseTime } from "../utils/helper"
|
||||||
import TaskTitle from "../components/submissions/TaskTitle.vue"
|
import { goHome } from "../utils/helper"
|
||||||
import Preview from "../components/Preview.vue"
|
import { TASK_TYPE } from "../utils/const"
|
||||||
import { submission } from "../store/submission"
|
|
||||||
import { useRouter, useRoute } from "vue-router"
|
|
||||||
import { watchDebounced } from "@vueuse/core"
|
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 { taskTab } from "../store/task"
|
||||||
import { step } from "../store/tutorial"
|
import { step } from "../store/tutorial"
|
||||||
import { html as eHtml, css as eCss, js as eJs } from "../store/editors"
|
import { html as eHtml, css as eCss, js as eJs } from "../store/editors"
|
||||||
import { TASK_TYPE } from "../utils/const"
|
import { roleAdmin, roleSuper, user } from "../store/user"
|
||||||
import { goHome } from "../utils/helper"
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 列表数据
|
||||||
const data = ref<SubmissionOut[]>([])
|
const data = ref<SubmissionOut[]>([])
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
page: Number(route.params.page),
|
page: Number(route.params.page),
|
||||||
username: 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 html = computed(() => submission.value.html)
|
||||||
const css = computed(() => submission.value.css)
|
const css = computed(() => submission.value.css)
|
||||||
const js = computed(() => submission.value.js)
|
const js = computed(() => submission.value.js)
|
||||||
|
|
||||||
|
// Modal 状态
|
||||||
const codeModal = ref(false)
|
const codeModal = ref(false)
|
||||||
const chainModal = ref(false)
|
const chainModal = ref(false)
|
||||||
const chainMessages = ref<{ id: number; role: string; content: string }[]>([])
|
const chainConversationId = ref<string | undefined>()
|
||||||
const chainLoading = ref(false)
|
|
||||||
|
|
||||||
async function showChain(conversationId: string) {
|
// 展开行
|
||||||
chainLoading.value = true
|
const expandedKeys = ref<string[]>([])
|
||||||
chainModal.value = true
|
const expandedData = reactive(new Map<string, SubmissionOut[]>())
|
||||||
try {
|
const expandedLoading = reactive(new Set<string>())
|
||||||
chainMessages.value = await Prompt.getMessages(conversationId)
|
|
||||||
} finally {
|
// 管理员判断
|
||||||
chainLoading.value = false
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarkdown(text: string): string {
|
async function updateFlag(row: SubmissionOut, flag: FlagType) {
|
||||||
return marked.parse(text, { async: false }) as string
|
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>[] = [
|
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: "时间",
|
title: "时间",
|
||||||
key: "created",
|
key: "created",
|
||||||
@@ -142,43 +224,76 @@ const columns: DataTableColumn<SubmissionOut>[] = [
|
|||||||
{
|
{
|
||||||
title: "任务",
|
title: "任务",
|
||||||
key: "task_title",
|
key: "task_title",
|
||||||
render: (submission) => h(TaskTitle, { submission }),
|
render: (row) => h(TaskTitle, { submission: row }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "我打的分",
|
title: "得分",
|
||||||
key: "my_score",
|
|
||||||
render: (row) => {
|
|
||||||
if (row.my_score > 0) return row.my_score
|
|
||||||
else return "-"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "平均得分",
|
|
||||||
key: "score",
|
key: "score",
|
||||||
render: (row) => {
|
|
||||||
if (row.score > 0) return row.score.toFixed(2)
|
|
||||||
else return "-"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "思维链",
|
|
||||||
key: "conversation_id",
|
|
||||||
width: 70,
|
width: 70,
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
if (!row.conversation_id) return "-"
|
const myScore = row.my_score > 0 ? String(row.my_score) : "-"
|
||||||
|
const avgScore = row.score > 0 ? row.score.toFixed(2) : "-"
|
||||||
return h(
|
return h(
|
||||||
NButton,
|
"div",
|
||||||
{ text: true, type: "primary", onClick: () => showChain(row.conversation_id!) },
|
{ 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) {
|
function rowProps(row: SubmissionOut) {
|
||||||
return {
|
return {
|
||||||
style: { cursor: "pointer" },
|
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],
|
||||||
|
)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +302,8 @@ function rowClassName(row: SubmissionOut) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
|
expandedKeys.value = []
|
||||||
|
expandedData.clear()
|
||||||
const res = await Submission.list(query)
|
const res = await Submission.list(query)
|
||||||
data.value = res.items
|
data.value = res.items
|
||||||
count.value = res.count
|
count.value = res.count
|
||||||
@@ -196,11 +313,26 @@ async function getSubmissionByID(id: string) {
|
|||||||
submission.value = await Submission.get(id)
|
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() {
|
function afterScore() {
|
||||||
data.value = data.value.map((d) => {
|
data.value = data.value.map((d) => {
|
||||||
if (d.id === submission.value.id) {
|
if (d.id === submission.value.id) d.my_score = submission.value.my_score
|
||||||
d.my_score = submission.value.my_score
|
|
||||||
}
|
|
||||||
return d
|
return d
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -227,6 +359,14 @@ watchDebounced(
|
|||||||
},
|
},
|
||||||
{ debounce: 500, maxWait: 1000 },
|
{ debounce: 500, maxWait: 1000 },
|
||||||
)
|
)
|
||||||
|
watch(
|
||||||
|
() => query.flag,
|
||||||
|
() => {
|
||||||
|
query.page = 1
|
||||||
|
init()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(init)
|
onMounted(init)
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
submission.value = {
|
submission.value = {
|
||||||
@@ -247,11 +387,12 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.container {
|
.container {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: calc(100% - 43px);
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.row-active td) {
|
:deep(.row-active td) {
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ const message = useMessage()
|
|||||||
const confirm = useDialog()
|
const confirm = useDialog()
|
||||||
|
|
||||||
const list = ref<TutorialSlim[]>([])
|
const list = ref<TutorialSlim[]>([])
|
||||||
const content = ref("")
|
|
||||||
const tutorial = reactive({
|
const tutorial = reactive({
|
||||||
display: 0,
|
display: 0,
|
||||||
title: "",
|
title: "",
|
||||||
@@ -102,7 +101,6 @@ function createNew() {
|
|||||||
tutorial.title = ""
|
tutorial.title = ""
|
||||||
tutorial.content = ""
|
tutorial.content = ""
|
||||||
tutorial.is_public = false
|
tutorial.is_public = false
|
||||||
content.value = ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -114,7 +112,6 @@ async function submit() {
|
|||||||
tutorial.content = ""
|
tutorial.content = ""
|
||||||
tutorial.is_public = false
|
tutorial.is_public = false
|
||||||
await getContent()
|
await getContent()
|
||||||
content.value = ""
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response.data.detail)
|
message.error(error.response.data.detail)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { ref } from "vue"
|
import { ref } from "vue"
|
||||||
import { WS_BASE_URL } from "../utils/const"
|
import { WS_BASE_URL } from "../utils/const"
|
||||||
import { html, css, js } from "./editors"
|
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 {
|
export interface PromptMessage {
|
||||||
role: "user" | "assistant"
|
role: "user" | "assistant"
|
||||||
@@ -13,7 +16,20 @@ export const messages = ref<PromptMessage[]>([])
|
|||||||
export const conversationId = ref<string>("")
|
export const conversationId = ref<string>("")
|
||||||
export const connected = ref(false)
|
export const connected = ref(false)
|
||||||
export const streaming = ref(false)
|
export const streaming = ref(false)
|
||||||
|
export const historyLoading = ref(false)
|
||||||
|
let _historyLoadId = 0
|
||||||
export const streamingContent = ref("")
|
export const streamingContent = ref("")
|
||||||
|
let _onCodeComplete:
|
||||||
|
| ((code: {
|
||||||
|
html: string | null
|
||||||
|
css: string | null
|
||||||
|
js: string | null
|
||||||
|
}) => void)
|
||||||
|
| null = null
|
||||||
|
|
||||||
|
export function setOnCodeComplete(fn: typeof _onCodeComplete) {
|
||||||
|
_onCodeComplete = fn
|
||||||
|
}
|
||||||
|
|
||||||
let ws: WebSocket | null = null
|
let ws: WebSocket | null = null
|
||||||
|
|
||||||
@@ -30,14 +46,20 @@ export function connectPrompt(taskId: number) {
|
|||||||
const data = JSON.parse(event.data)
|
const data = JSON.parse(event.data)
|
||||||
|
|
||||||
if (data.type === "init") {
|
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
|
conversationId.value = data.conversation_id
|
||||||
messages.value = data.messages || []
|
if (!alreadyLoaded) {
|
||||||
// Apply code from last assistant message if exists
|
messages.value = data.messages || []
|
||||||
const lastAssistant = [...messages.value]
|
// Apply code from last assistant message if exists
|
||||||
.reverse()
|
// (skipped when HTTP preload already loaded and applied)
|
||||||
.find((m) => m.role === "assistant" && m.code)
|
const lastAssistant = [...messages.value]
|
||||||
if (lastAssistant?.code) {
|
.reverse()
|
||||||
applyCode(lastAssistant.code)
|
.find((m) => m.role === "assistant" && m.code)
|
||||||
|
if (lastAssistant?.code) {
|
||||||
|
applyCode(lastAssistant.code)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (data.type === "stream") {
|
} else if (data.type === "stream") {
|
||||||
streaming.value = true
|
streaming.value = true
|
||||||
@@ -54,6 +76,9 @@ export function connectPrompt(taskId: number) {
|
|||||||
// Apply code to editors
|
// Apply code to editors
|
||||||
if (data.code) {
|
if (data.code) {
|
||||||
applyCode(data.code)
|
applyCode(data.code)
|
||||||
|
if (_onCodeComplete) {
|
||||||
|
_onCodeComplete(data.code)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (data.type === "error") {
|
} else if (data.type === "error") {
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
@@ -71,6 +96,8 @@ export function connectPrompt(taskId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function disconnectPrompt() {
|
export function disconnectPrompt() {
|
||||||
|
_historyLoadId++ // cancel any in-flight loadHistory
|
||||||
|
historyLoading.value = false // reset here; finally block won't (loadId mismatch)
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.close()
|
ws.close()
|
||||||
ws = null
|
ws = null
|
||||||
@@ -80,6 +107,59 @@ export function disconnectPrompt() {
|
|||||||
connected.value = false
|
connected.value = false
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
streamingContent.value = ""
|
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) {
|
export function sendPrompt(content: string) {
|
||||||
@@ -93,7 +173,11 @@ export function newConversation() {
|
|||||||
ws.send(JSON.stringify({ type: "new_conversation" }))
|
ws.send(JSON.stringify({ type: "new_conversation" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCode(code: { html: string | null; css: string | null; js: string | null }) {
|
function applyCode(code: {
|
||||||
|
html: string | null
|
||||||
|
css: string | null
|
||||||
|
js: string | null
|
||||||
|
}) {
|
||||||
if (code.html !== null) html.value = code.html
|
if (code.html !== null) html.value = code.html
|
||||||
if (code.css !== null) css.value = code.css
|
if (code.css !== null) css.value = code.css
|
||||||
if (code.js !== null) js.value = code.js
|
if (code.js !== null) js.value = code.js
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ export const ADMIN_URL = import.meta.env.PUBLIC_ADMIN_URL
|
|||||||
|
|
||||||
export const BASE_URL = import.meta.env.PUBLIC_BASE_URL
|
export const BASE_URL = import.meta.env.PUBLIC_BASE_URL
|
||||||
|
|
||||||
export const WS_BASE_URL = import.meta.env.PUBLIC_WS_URL || `ws://${window.location.host}`
|
export const WS_BASE_URL =
|
||||||
|
import.meta.env.PUBLIC_WS_URL || `ws://${window.location.host}`
|
||||||
|
|
||||||
export enum TASK_TYPE {
|
export enum TASK_TYPE {
|
||||||
Tutorial = "tutorial",
|
Tutorial = "tutorial",
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import type { TASK_TYPE } from "./const"
|
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 {
|
export enum Role {
|
||||||
Super = "super",
|
Super = "super",
|
||||||
Admin = "admin",
|
Admin = "admin",
|
||||||
@@ -14,6 +24,8 @@ export function getRole(role: Role) {
|
|||||||
}[role]
|
}[role]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FlagType = "red" | "blue" | "green" | "yellow" | null
|
||||||
|
|
||||||
export interface TutorialSlim {
|
export interface TutorialSlim {
|
||||||
display: number
|
display: number
|
||||||
title: string
|
title: string
|
||||||
@@ -58,12 +70,16 @@ export interface SubmissionOut {
|
|||||||
id: string
|
id: string
|
||||||
userid: number
|
userid: number
|
||||||
username: string
|
username: string
|
||||||
|
task_id: number
|
||||||
task_display: number
|
task_display: number
|
||||||
task_type: TASK_TYPE
|
task_type: TASK_TYPE
|
||||||
task_title: string
|
task_title: string
|
||||||
score: number
|
score: number
|
||||||
my_score: number
|
my_score: number
|
||||||
conversation_id?: string
|
conversation_id?: string
|
||||||
|
flag?: FlagType
|
||||||
|
nominated: boolean
|
||||||
|
submit_count: number
|
||||||
created: Date
|
created: Date
|
||||||
modified: Date
|
modified: Date
|
||||||
}
|
}
|
||||||
@@ -78,9 +94,60 @@ export interface SubmissionAll {
|
|||||||
task_title: string
|
task_title: string
|
||||||
score: number
|
score: number
|
||||||
my_score: number
|
my_score: number
|
||||||
|
flag?: FlagType
|
||||||
html: ""
|
html: ""
|
||||||
css: ""
|
css: ""
|
||||||
js: ""
|
js: ""
|
||||||
created: Date
|
created: Date
|
||||||
modified: Date
|
modified: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserTag {
|
||||||
|
username: string
|
||||||
|
classname: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopSubmission {
|
||||||
|
submission_id: string
|
||||||
|
username: string
|
||||||
|
classname: string
|
||||||
|
score: number
|
||||||
|
rating_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmissionCountBucket {
|
||||||
|
count_1: number
|
||||||
|
count_2: number
|
||||||
|
count_3: number
|
||||||
|
count_4_plus: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreBucket {
|
||||||
|
range_1_2: number
|
||||||
|
range_2_3: number
|
||||||
|
range_3_4: number
|
||||||
|
range_4_5: number
|
||||||
|
range_5: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlagStats {
|
||||||
|
red: number
|
||||||
|
blue: number
|
||||||
|
green: number
|
||||||
|
yellow: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskStatsOut {
|
||||||
|
submitted_count: number
|
||||||
|
unsubmitted_count: number
|
||||||
|
average_score: number | null
|
||||||
|
unrated_count: number
|
||||||
|
nominated_count: number
|
||||||
|
unsubmitted_users: UserTag[]
|
||||||
|
unrated_users: UserTag[]
|
||||||
|
submission_count_distribution: SubmissionCountBucket
|
||||||
|
score_distribution: ScoreBucket
|
||||||
|
top_submissions: TopSubmission[]
|
||||||
|
flag_stats: FlagStats
|
||||||
|
classes: string[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,5 +10,5 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user