Compare commits
30 Commits
45b40f13ad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| efb535eb3d | |||
| 819b7a82f4 | |||
| 0180f6e803 | |||
| 80aa091602 | |||
| 983e87403c | |||
| 5aaba42068 | |||
| 5ecf8caf83 | |||
| 2b216878ca | |||
| 77aca640ac | |||
| 9a8e5ad48e | |||
| 4af9ae90c9 | |||
| f63f7cbbce | |||
| 21e3a7f39b | |||
| f255367b08 | |||
| edbf66874b | |||
| e8992edabc | |||
| f3eed84f7c | |||
| b5e0421fd4 | |||
| c0084462eb | |||
| 5fced6b4c2 | |||
| 2e6e3aacec | |||
| 64dc1c9234 | |||
| 2abf95888b | |||
| eff635fb49 | |||
| 3136be2df7 | |||
| efecef4e98 | |||
| f025ebfa2e | |||
| 375a78b852 | |||
| dd249c8753 | |||
| 7af5e3117d |
1
.browserslistrc
Normal file
1
.browserslistrc
Normal file
@@ -0,0 +1 @@
|
||||
chrome >= 90
|
||||
2
.github/workflows/deploy.yaml
vendored
2
.github/workflows/deploy.yaml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm install
|
||||
- run: npm run ${{ matrix.build_command }}
|
||||
env:
|
||||
CI: false
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
.worktrees/
|
||||
*.local
|
||||
components.d.ts
|
||||
|
||||
|
||||
1188
package-lock.json
generated
1188
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -12,9 +12,9 @@
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"axios": "^1.15.0",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"axios": "^1.16.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"copy-text-to-clipboard": "^3.2.2",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
@@ -23,19 +23,20 @@
|
||||
"marked-alert": "^2.1.2",
|
||||
"marked-code-preview": "^1.3.7",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"md-editor-v3": "^6.4.2",
|
||||
"md-editor-v3": "^6.5.0",
|
||||
"naive-ui": "^2.44.1",
|
||||
"vue": "^3.5.32",
|
||||
"vue": "^3.5.33",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
"vue-router": "^5.0.4"
|
||||
"vue-router": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@rsbuild/core": "^1.7.5",
|
||||
"@rsbuild/core": "^2.0.3",
|
||||
"@rsbuild/plugin-vue": "^1.2.7",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"prettier": "^3.8.2",
|
||||
"typescript": "^6.0.2",
|
||||
"core-js": "^3.49.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^6.0.3",
|
||||
"unplugin-vue-components": "^32.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ export default defineConfig({
|
||||
template: "./index.html",
|
||||
},
|
||||
source: {
|
||||
include: [/node_modules[\\/]marked[\\/]/],
|
||||
entry: {
|
||||
index: "./src/main.ts",
|
||||
},
|
||||
},
|
||||
output: {
|
||||
target: "web",
|
||||
polyfill: "usage",
|
||||
},
|
||||
tools: {
|
||||
@@ -39,16 +39,4 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
performance: {
|
||||
chunkSplit: {
|
||||
// strategy: "split-by-experience",
|
||||
strategy: "split-by-module",
|
||||
// forceSplitting: {
|
||||
// "lib-ui": /node_modules[\\/]naive-ui[\\/]/,
|
||||
// "lib-cm": /node_modules[\\/]@codemirror[\\/]/,
|
||||
// "lib-marked": /node_modules[\\/]marked[\\/]/,
|
||||
// "lib-hljs": /node_modules[\\/]highlight\.js[\\/]/,
|
||||
// },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
152
src/api.ts
152
src/api.ts
@@ -6,8 +6,20 @@ import type {
|
||||
FlagType,
|
||||
SubmissionOut,
|
||||
PromptMessage,
|
||||
PromptHistoryItem,
|
||||
TaskStatsOut,
|
||||
TaskAsset,
|
||||
AwardSection,
|
||||
AwardManageIn,
|
||||
AwardManageOut,
|
||||
AwardItemIn,
|
||||
AwardItemUpdateIn,
|
||||
AwardItemManageOut,
|
||||
GradebookOut,
|
||||
GradebookQuery,
|
||||
ShowcaseSubmissionLookupOut,
|
||||
ShowcaseDetail,
|
||||
PromptRound,
|
||||
} from "./utils/type"
|
||||
import { BASE_URL, STORAGE_KEY } from "./utils/const"
|
||||
|
||||
@@ -206,6 +218,11 @@ export const Submission = {
|
||||
return res.data
|
||||
},
|
||||
|
||||
async getPromptChain(id: string): Promise<PromptRound[]> {
|
||||
const res = await http.get(`/submission/${id}/prompt-chain`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const res = await http.delete("/submission/" + id)
|
||||
return res.data
|
||||
@@ -238,6 +255,50 @@ export const Submission = {
|
||||
},
|
||||
}
|
||||
|
||||
function gradebookParams(query: GradebookQuery) {
|
||||
const params: Record<string, string | boolean> = {
|
||||
classname: query.classname,
|
||||
}
|
||||
if (query.task_type) params.task_type = query.task_type
|
||||
if (query.username) params.username = query.username
|
||||
if (query.include_all_tasks) params.include_all_tasks = true
|
||||
return params
|
||||
}
|
||||
|
||||
function filenameFromDisposition(
|
||||
disposition: string | undefined,
|
||||
fallback: string,
|
||||
) {
|
||||
const match = disposition?.match(/filename\*=UTF-8''([^;]+)/)
|
||||
return match ? decodeURIComponent(match[1]) : fallback
|
||||
}
|
||||
|
||||
export const Gradebook = {
|
||||
async get(query: GradebookQuery): Promise<GradebookOut> {
|
||||
const res = await http.get("/submission/gradebook/", {
|
||||
params: gradebookParams(query),
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
|
||||
async downloadCsv(query: GradebookQuery) {
|
||||
const res = await http.get("/submission/gradebook/export/", {
|
||||
params: gradebookParams(query),
|
||||
responseType: "blob",
|
||||
})
|
||||
const blob = new Blob([res.data], { type: "text/csv;charset=utf-8" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filenameFromDisposition(
|
||||
res.headers["content-disposition"],
|
||||
`gradebook-${query.classname}.csv`,
|
||||
)
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
}
|
||||
|
||||
export const Prompt = {
|
||||
async listConversations(taskId?: number, userId?: number) {
|
||||
const params: Record<string, number> = {}
|
||||
@@ -246,6 +307,11 @@ export const Prompt = {
|
||||
return (await http.get("/prompt/conversations/", { params })).data
|
||||
},
|
||||
|
||||
async listHistory(taskId: number): Promise<PromptHistoryItem[]> {
|
||||
const res = await http.get(`/prompt/history/${taskId}`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async getMessages(conversationId: string): Promise<PromptMessage[]> {
|
||||
return (
|
||||
await http.get<PromptMessage[]>(
|
||||
@@ -282,6 +348,92 @@ export const Helper = {
|
||||
},
|
||||
}
|
||||
|
||||
export const Showcase = {
|
||||
async list(): Promise<AwardSection[]> {
|
||||
const res = await http.get("/submission/showcase/")
|
||||
return res.data
|
||||
},
|
||||
|
||||
async listManageAwards(): Promise<AwardManageOut[]> {
|
||||
const res = await http.get("/submission/showcase/manage/awards")
|
||||
return res.data
|
||||
},
|
||||
|
||||
async createAward(payload: AwardManageIn): Promise<AwardManageOut> {
|
||||
const res = await http.post("/submission/showcase/manage/awards", payload)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async updateAward(
|
||||
id: number,
|
||||
payload: AwardManageIn,
|
||||
): Promise<AwardManageOut> {
|
||||
const res = await http.put(
|
||||
`/submission/showcase/manage/awards/${id}`,
|
||||
payload,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async deleteAward(id: number) {
|
||||
const res = await http.delete(`/submission/showcase/manage/awards/${id}`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async listAwardItems(id: number): Promise<AwardItemManageOut[]> {
|
||||
const res = await http.get(`/submission/showcase/manage/awards/${id}/items`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async findSubmissionForAward(
|
||||
submissionId: string,
|
||||
): Promise<ShowcaseSubmissionLookupOut> {
|
||||
const res = await http.get(
|
||||
`/submission/showcase/manage/submissions/${submissionId}`,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async addAwardItem(
|
||||
id: number,
|
||||
payload: AwardItemIn,
|
||||
): Promise<AwardItemManageOut> {
|
||||
const res = await http.post(
|
||||
`/submission/showcase/manage/awards/${id}/items`,
|
||||
payload,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async updateAwardItem(
|
||||
itemId: number,
|
||||
payload: AwardItemUpdateIn,
|
||||
): Promise<AwardItemManageOut> {
|
||||
const res = await http.put(
|
||||
`/submission/showcase/manage/items/${itemId}`,
|
||||
payload,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async deleteAwardItem(itemId: number) {
|
||||
const res = await http.delete(`/submission/showcase/manage/items/${itemId}`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async getDetail(submissionId: string): Promise<ShowcaseDetail> {
|
||||
const res = await http.get(`/submission/showcase/${submissionId}/`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
async getPromptChain(submissionId: string): Promise<PromptRound[]> {
|
||||
const res = await http.get(
|
||||
`/submission/showcase/${submissionId}/prompt-chain/`,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
}
|
||||
|
||||
export const TaskAssets = {
|
||||
async listChallenge(display: number): Promise<TaskAsset[]> {
|
||||
return (await http.get<TaskAsset[]>(`/assets/challenge/${display}`)).data
|
||||
|
||||
@@ -52,6 +52,9 @@ import { html, css, js } from "../../store/editors"
|
||||
import { Submission } from "../../api"
|
||||
|
||||
const props = defineProps<{ taskId: number }>()
|
||||
const emit = defineEmits<{
|
||||
submitted: []
|
||||
}>()
|
||||
const message = useMessage()
|
||||
|
||||
const promptText = ref("")
|
||||
@@ -105,6 +108,7 @@ async function submit() {
|
||||
js: splitResult.value.js,
|
||||
prompt: promptText.value.trim(),
|
||||
})
|
||||
emit("submitted")
|
||||
message.success("提交成功")
|
||||
promptText.value = ""
|
||||
rawCode.value = ""
|
||||
|
||||
316
src/components/ai/PromptHistoryPanel.vue
Normal file
316
src/components/ai/PromptHistoryPanel.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<div class="history-panel">
|
||||
<n-flex
|
||||
class="history-toolbar"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
:wrap="false"
|
||||
>
|
||||
<n-text depth="3">共 {{ items.length }} 条历史对话</n-text>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button
|
||||
quaternary
|
||||
circle
|
||||
size="small"
|
||||
:loading="loading"
|
||||
aria-label="刷新历史对话"
|
||||
@click="load(true)"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:refresh-cw" />
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
刷新历史对话
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
|
||||
<n-spin v-if="loading" class="state" />
|
||||
<n-empty
|
||||
v-else-if="!items.length"
|
||||
class="state"
|
||||
description="暂无历史对话"
|
||||
/>
|
||||
<n-scrollbar v-else class="history-scrollbar">
|
||||
<n-flex vertical :size="12" class="history-list">
|
||||
<n-card
|
||||
v-for="(item, index) in items"
|
||||
:key="item.assistant_message_id"
|
||||
class="history-card"
|
||||
:class="{
|
||||
'is-selected':
|
||||
selectedAssistantMessageId === item.assistant_message_id,
|
||||
}"
|
||||
size="small"
|
||||
:bordered="true"
|
||||
hoverable
|
||||
:embedded="selectedAssistantMessageId === item.assistant_message_id"
|
||||
:content-style="{ padding: 0 }"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<n-flex
|
||||
class="history-main"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
:wrap="false"
|
||||
>
|
||||
<n-flex align="center" :wrap="false" :size="6">
|
||||
<n-tag
|
||||
round
|
||||
size="small"
|
||||
:bordered="false"
|
||||
:type="
|
||||
selectedAssistantMessageId === item.assistant_message_id
|
||||
? 'success'
|
||||
: 'default'
|
||||
"
|
||||
>
|
||||
#{{ index + 1 }}
|
||||
</n-tag>
|
||||
<n-tag
|
||||
size="small"
|
||||
:type="item.source === 'manual' ? 'info' : 'success'"
|
||||
>
|
||||
{{ item.source === "manual" ? "手动提交" : "AI 对话" }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-tag
|
||||
v-if="selectedAssistantMessageId === item.assistant_message_id"
|
||||
size="small"
|
||||
type="success"
|
||||
:bordered="false"
|
||||
>
|
||||
正在预览
|
||||
</n-tag>
|
||||
<n-text depth="3">
|
||||
{{ parseTime(item.created, "YYYY-MM-DD HH:mm") }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
<div
|
||||
class="prompt-markdown markdown-body"
|
||||
v-html="renderMarkdown(item.prompt)"
|
||||
/>
|
||||
<div class="thumbnail" aria-label="页面缩略图">
|
||||
<iframe
|
||||
v-if="item.hasPage"
|
||||
title="页面缩略图"
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts"
|
||||
referrerpolicy="no-referrer"
|
||||
tabindex="-1"
|
||||
:srcdoc="item.previewDoc"
|
||||
/>
|
||||
<n-empty v-else size="small" description="未生成页面" />
|
||||
</div>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from "vue"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { marked } from "marked"
|
||||
import { Prompt } from "../../api"
|
||||
import type { PromptHistoryItem } from "../../utils/type"
|
||||
import { parseTime } from "../../utils/helper"
|
||||
import { buildPreviewDocument } from "../../utils/previewDocument"
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
active: boolean
|
||||
assetBaseUrl?: string
|
||||
refreshKey?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [code: { html: string; css: string; js: string }]
|
||||
}>()
|
||||
|
||||
type HistoryViewItem = PromptHistoryItem & {
|
||||
hasPage: boolean
|
||||
previewDoc: string
|
||||
}
|
||||
|
||||
const items = ref<HistoryViewItem[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedAssistantMessageId = ref<number | null>(null)
|
||||
let loadedTaskId = 0
|
||||
|
||||
function toViewItem(item: PromptHistoryItem): HistoryViewItem {
|
||||
const html = item.code_html ?? ""
|
||||
const css = item.code_css ?? ""
|
||||
const js = item.code_js ?? ""
|
||||
return {
|
||||
...item,
|
||||
hasPage: !!(html.trim() || css.trim() || js.trim()),
|
||||
previewDoc: buildPreviewDocument({
|
||||
html,
|
||||
css,
|
||||
js,
|
||||
assetBaseUrl: props.assetBaseUrl,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
return marked.parse(text) as string
|
||||
}
|
||||
|
||||
function selectItem(item: HistoryViewItem) {
|
||||
selectedAssistantMessageId.value = item.assistant_message_id
|
||||
emit("select", {
|
||||
html: item.code_html ?? "",
|
||||
css: item.code_css ?? "",
|
||||
js: item.code_js ?? "",
|
||||
})
|
||||
}
|
||||
|
||||
async function load(force = true) {
|
||||
if (!props.taskId || loading.value) return
|
||||
if (!force && loadedTaskId === props.taskId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const taskChanged = loadedTaskId !== props.taskId
|
||||
const data = await Prompt.listHistory(props.taskId)
|
||||
items.value = data.map(toViewItem)
|
||||
if (
|
||||
taskChanged ||
|
||||
!items.value.some(
|
||||
(item) =>
|
||||
item.assistant_message_id === selectedAssistantMessageId.value,
|
||||
)
|
||||
) {
|
||||
selectedAssistantMessageId.value = null
|
||||
}
|
||||
loadedTaskId = props.taskId
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.active, props.taskId] as const,
|
||||
([active]) => {
|
||||
if (active) load(false)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.refreshKey,
|
||||
() => {
|
||||
if (props.active) load(true)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.active) load(false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-toolbar {
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--n-border-color, #efeff5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.state {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.history-scrollbar {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-card.is-selected {
|
||||
--n-color: #f7fffa;
|
||||
box-shadow: 0 10px 24px rgba(24, 160, 88, 0.14);
|
||||
}
|
||||
|
||||
.history-card.is-selected .history-main {
|
||||
background: #eefaf3;
|
||||
}
|
||||
|
||||
.history-main {
|
||||
padding: 10px 12px;
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.prompt-markdown {
|
||||
margin: 0;
|
||||
padding: 0 12px 12px;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.prompt-markdown :deep(p),
|
||||
.prompt-markdown :deep(ul),
|
||||
.prompt-markdown :deep(ol),
|
||||
.prompt-markdown :deep(blockquote),
|
||||
.prompt-markdown :deep(pre) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.prompt-markdown :deep(:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prompt-markdown :deep(pre) {
|
||||
padding: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prompt-markdown :deep(code) {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
height: 160px;
|
||||
border-top: 1px solid var(--n-border-color, #efeff5);
|
||||
background: #f7f7f9;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.history-card.is-selected .thumbnail {
|
||||
border-top-color: #d8f1e2;
|
||||
background: #f4fbf6;
|
||||
}
|
||||
|
||||
.thumbnail iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -98,9 +98,9 @@ const naiveMessage = useMessage()
|
||||
|
||||
const modelOptions = [
|
||||
{ label: "豆包", value: "doubao-seed-2-0-lite-260215" },
|
||||
{ label: "DeepSeek", value: "deepseek-chat" },
|
||||
{ label: "DeepSeek", value: "deepseek-v4-flash" },
|
||||
]
|
||||
const selectedModel = useStorage("prompt-model", "deepseek-chat")
|
||||
const selectedModel = useStorage("prompt-model", "deepseek-v4-flash")
|
||||
|
||||
// Group messages into user+assistant pairs
|
||||
const pairs = computed(() => {
|
||||
|
||||
@@ -42,18 +42,19 @@
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<div class="iframe-wrapper" :style="iframeWrapperStyle">
|
||||
<iframe class="iframe" ref="iframe"></iframe>
|
||||
<iframe class="iframe" :srcdoc="previewContent"></iframe>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
import { computed, onMounted, useTemplateRef, ref } from "vue"
|
||||
import { computed, onMounted, ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { Submission } from "../../api"
|
||||
import { submission } from "../../store/submission"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import copy from "copy-text-to-clipboard"
|
||||
import { buildPreviewDocument } from "../../utils/previewDocument"
|
||||
|
||||
interface Props {
|
||||
html: string
|
||||
@@ -91,7 +92,9 @@ const layoutConfig: Record<
|
||||
},
|
||||
}
|
||||
const layoutIndex = ref(0)
|
||||
const layoutIcon = computed(() => layoutConfig[layouts[layoutIndex.value]!].icon)
|
||||
const layoutIcon = computed(
|
||||
() => layoutConfig[layouts[layoutIndex.value]!].icon,
|
||||
)
|
||||
const layoutLabel = computed(
|
||||
() => layoutConfig[layouts[layoutIndex.value]!].label,
|
||||
)
|
||||
@@ -105,35 +108,20 @@ function cycleLayout() {
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const iframe = useTemplateRef<HTMLIFrameElement>("iframe")
|
||||
const showDL = computed(() => props.html || props.css || props.js)
|
||||
const previewContent = ref("")
|
||||
|
||||
function getContent() {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-Hans-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>预览</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
${props.assetBaseUrl ? `<base href="${props.assetBaseUrl}">` : ""}
|
||||
<style>${props.css}</style>
|
||||
<link rel="stylesheet" href="/normalize.min.css" />
|
||||
</head>
|
||||
<body>
|
||||
${props.html}
|
||||
<script>${props.js}<\/script>
|
||||
</body>
|
||||
</html>`
|
||||
return buildPreviewDocument({
|
||||
html: props.html,
|
||||
css: props.css,
|
||||
js: props.js,
|
||||
assetBaseUrl: props.assetBaseUrl,
|
||||
})
|
||||
}
|
||||
|
||||
function preview() {
|
||||
if (!iframe.value) return
|
||||
const doc = iframe.value.contentDocument
|
||||
if (doc) {
|
||||
doc.open()
|
||||
doc.write(getContent())
|
||||
doc.close()
|
||||
}
|
||||
previewContent.value = getContent()
|
||||
}
|
||||
|
||||
function download() {
|
||||
@@ -155,7 +143,7 @@ function open() {
|
||||
})
|
||||
window.open(data.href, "_blank")
|
||||
} else {
|
||||
const newTab = window.open("/usercontent.html")
|
||||
const newTab = window.open("about:blank", "_blank")
|
||||
if (!newTab) return
|
||||
newTab.document.open()
|
||||
newTab.document.write(getContent())
|
||||
@@ -183,10 +171,14 @@ async function updateScore(score: number) {
|
||||
}
|
||||
}
|
||||
|
||||
watchDebounced(() => [props.html, props.css, props.js], preview, {
|
||||
watchDebounced(
|
||||
() => [props.html, props.css, props.js, props.assetBaseUrl],
|
||||
preview,
|
||||
{
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
},
|
||||
)
|
||||
onMounted(preview)
|
||||
</script>
|
||||
<style scoped>
|
||||
|
||||
@@ -130,20 +130,40 @@
|
||||
gap: 8px;
|
||||
"
|
||||
>
|
||||
<span>{{ round.question }}</span>
|
||||
<div style="display: flex; flex-direction: row; align-items: center; gap: 4px; flex-shrink: 0">
|
||||
<div
|
||||
class="prompt-markdown markdown-body"
|
||||
:class="{
|
||||
'is-collapsed':
|
||||
isPromptLong(round.question) && !isExpanded(index),
|
||||
}"
|
||||
v-html="renderMarkdown(round.question)"
|
||||
></div>
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="round.source"
|
||||
:style="{
|
||||
fontSize: '10px',
|
||||
padding: '1px 5px',
|
||||
borderRadius: '4px',
|
||||
background: round.source === 'conversation' ? '#e8f0fe' : '#f0f0f0',
|
||||
color: round.source === 'conversation' ? '#2060c0' : '#888',
|
||||
background:
|
||||
round.source === 'conversation' ? '#e8f0fe' : '#f0f0f0',
|
||||
color:
|
||||
round.source === 'conversation' ? '#2060c0' : '#888',
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
}"
|
||||
>{{ round.source === "conversation" ? "对话" : "手动" }}</span>
|
||||
>{{
|
||||
round.source === "conversation" ? "对话" : "手动"
|
||||
}}</span
|
||||
>
|
||||
<span
|
||||
v-if="round.prompt_level"
|
||||
:style="{
|
||||
@@ -153,6 +173,23 @@
|
||||
}"
|
||||
>L{{ round.prompt_level }}</span
|
||||
>
|
||||
<n-button
|
||||
v-if="isPromptLong(round.question)"
|
||||
text
|
||||
size="tiny"
|
||||
type="primary"
|
||||
@click.stop="toggleExpanded(index)"
|
||||
>
|
||||
<Icon
|
||||
:icon="
|
||||
isExpanded(index)
|
||||
? 'lucide:chevron-up'
|
||||
: 'lucide:chevron-down'
|
||||
"
|
||||
:width="12"
|
||||
/>
|
||||
{{ isExpanded(index) ? "收起" : "展开" }}
|
||||
</n-button>
|
||||
<n-popconfirm
|
||||
v-if="round.assistantMsgId && canDelete"
|
||||
:show-icon="false"
|
||||
@@ -203,65 +240,32 @@
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { NPopconfirm, NButton } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { Prompt } from "../../api"
|
||||
import type { PromptMessage } from "../../utils/type"
|
||||
import { marked } from "marked"
|
||||
import { Prompt, Submission } from "../../api"
|
||||
import type { PromptRound } from "../../utils/type"
|
||||
import { user, roleSuper } from "../../store/user"
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
userId: number
|
||||
taskId: number
|
||||
submissionId: string
|
||||
username?: string
|
||||
}>()
|
||||
|
||||
const canDelete = computed(() => roleSuper.value || (!!props.username && props.username === user.username))
|
||||
const canDelete = computed(
|
||||
() =>
|
||||
roleSuper.value || (!!props.username && props.username === user.username),
|
||||
)
|
||||
|
||||
defineEmits<{ "update:show": [value: boolean] }>()
|
||||
|
||||
const loading = ref(false)
|
||||
const messages = ref<PromptMessage[]>([])
|
||||
const selectedRound = ref(0)
|
||||
|
||||
const rounds = computed(() => {
|
||||
const result: {
|
||||
question: string
|
||||
const expandedRounds = ref<Set<number>>(new Set())
|
||||
type ChainRound = Omit<PromptRound, "source"> & {
|
||||
source: string | null
|
||||
prompt_level: number | null
|
||||
assistantMsgId: number | null
|
||||
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,
|
||||
assistantMsgId: number | null = null
|
||||
for (const reply of messages.value.slice(i + 1)) {
|
||||
if (reply.role === "user") break
|
||||
if (reply.role === "assistant") {
|
||||
assistantMsgId = reply.id
|
||||
if (reply.code_html) {
|
||||
html = reply.code_html
|
||||
css = reply.code_css
|
||||
js = reply.code_js
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
result.push({
|
||||
question: msg.content,
|
||||
source: msg.source ?? null,
|
||||
prompt_level: msg.prompt_level ?? null,
|
||||
assistantMsgId,
|
||||
html,
|
||||
css,
|
||||
js,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
const rounds = ref<ChainRound[]>([])
|
||||
|
||||
async function deleteRound(index: number) {
|
||||
const round = rounds.value[index]
|
||||
@@ -290,27 +294,95 @@ const selectedPageHtml = computed(() => {
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8">${style}</head><body>${round.html}${script}</body></html>`
|
||||
})
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
return marked.parse(text) as string
|
||||
}
|
||||
|
||||
function isPromptLong(text: string): boolean {
|
||||
return text.length > 220 || text.split(/\r?\n/).length > 4
|
||||
}
|
||||
|
||||
function isExpanded(index: number): boolean {
|
||||
return expandedRounds.value.has(index)
|
||||
}
|
||||
|
||||
function toggleExpanded(index: number) {
|
||||
const next = new Set(expandedRounds.value)
|
||||
if (next.has(index)) {
|
||||
next.delete(index)
|
||||
} else {
|
||||
next.add(index)
|
||||
}
|
||||
expandedRounds.value = next
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
if (!props.userId || !props.taskId) return
|
||||
if (!props.submissionId) return
|
||||
loading.value = true
|
||||
messages.value = []
|
||||
rounds.value = []
|
||||
selectedRound.value = 0
|
||||
expandedRounds.value = new Set()
|
||||
try {
|
||||
messages.value = await Prompt.getMessagesByUserTask(
|
||||
props.taskId,
|
||||
props.userId,
|
||||
)
|
||||
const data = await Submission.getPromptChain(props.submissionId)
|
||||
rounds.value = data.map((round) => ({
|
||||
...round,
|
||||
source: round.source ?? null,
|
||||
assistantMsgId: round.assistant_msg_id ?? null,
|
||||
}))
|
||||
const last = rounds.value.length - 1
|
||||
if (last >= 0) selectedRound.value = last
|
||||
} catch {
|
||||
rounds.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.show, props.userId, props.taskId] as const,
|
||||
() => [props.show, props.submissionId] as const,
|
||||
([visible]) => {
|
||||
if (visible) loadMessages()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.prompt-markdown {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.prompt-markdown.is-collapsed {
|
||||
position: relative;
|
||||
max-height: 126px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prompt-markdown :deep(p),
|
||||
.prompt-markdown :deep(ul),
|
||||
.prompt-markdown :deep(ol),
|
||||
.prompt-markdown :deep(blockquote),
|
||||
.prompt-markdown :deep(pre) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.prompt-markdown :deep(:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prompt-markdown :deep(pre) {
|
||||
padding: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prompt-markdown :deep(code) {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,7 +36,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
select: [id: string]
|
||||
delete: [row: SubmissionOut, parentId: string]
|
||||
"show-chain": [userId: number, taskId: number, username: string]
|
||||
"show-chain": [submissionId: string, username: string]
|
||||
}>()
|
||||
|
||||
const isChallenge = computed(() => props.row.task_type === TASK_TYPE.Challenge)
|
||||
@@ -91,7 +91,7 @@ const subColumns = computed((): DataTableColumn<SubmissionOut>[] => [
|
||||
type: "primary",
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
emit("show-chain", r.userid, r.task_id, r.username)
|
||||
emit("show-chain", r.id, r.username)
|
||||
},
|
||||
},
|
||||
() => "查看",
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
>
|
||||
</n-flex>
|
||||
</template>
|
||||
<n-text depth="3" class="challenge-author">
|
||||
出题人:{{ item.author_name || "未设置" }}
|
||||
</n-text>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</div>
|
||||
@@ -72,4 +75,9 @@ onMounted(async () => {
|
||||
.submitted-title {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.challenge-author {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,6 +57,14 @@
|
||||
</template>
|
||||
提交记录
|
||||
</n-tooltip>
|
||||
<n-tooltip v-if="authed" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-button text @click="$router.push({ name: 'showcase' })">
|
||||
<Icon :width="16" icon="lucide:award"></Icon>
|
||||
</n-button>
|
||||
</template>
|
||||
创意工坊
|
||||
</n-tooltip>
|
||||
<n-tooltip v-if="roleSuper" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-button text @click="edit">
|
||||
|
||||
@@ -34,18 +34,34 @@
|
||||
</n-flex>
|
||||
</template>
|
||||
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
|
||||
<div class="desc-pane">
|
||||
<div class="challenge-meta">
|
||||
<n-text depth="3">
|
||||
出题人:{{ challengeAuthor || "未设置" }}
|
||||
</n-text>
|
||||
</div>
|
||||
<div
|
||||
class="markdown-body content no-select"
|
||||
v-html="challengeContent"
|
||||
ref="$desc"
|
||||
@copy.prevent
|
||||
/>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
|
||||
<PromptPanel />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="external" tab="手动提交" display-directive="show">
|
||||
<ExternalAIPanel :task-id="taskId" />
|
||||
<ExternalAIPanel :task-id="taskId" @submitted="historyRefreshKey++" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="history" tab="历史对话" display-directive="show">
|
||||
<PromptHistoryPanel
|
||||
:task-id="taskId"
|
||||
:active="activeTab === 'history'"
|
||||
:asset-base-url="assetBaseUrl"
|
||||
:refresh-key="historyRefreshKey"
|
||||
@select="previewHistoryItem"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
@@ -109,6 +125,7 @@ import { marked, type MarkedOptions } from "marked"
|
||||
import copyFn from "copy-text-to-clipboard"
|
||||
import PromptPanel from "../components/ai/PromptPanel.vue"
|
||||
import ExternalAIPanel from "../components/ai/ExternalAIPanel.vue"
|
||||
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
|
||||
import Preview from "../components/editor/Preview.vue"
|
||||
import TaskStatsModal from "../components/task/TaskStatsModal.vue"
|
||||
import { Challenge, Submission, TaskAssets } from "../api"
|
||||
@@ -156,11 +173,13 @@ function setupCodeCopy() {
|
||||
|
||||
const activeTab = ref("desc")
|
||||
const challengeContent = ref("")
|
||||
const challengeAuthor = ref("")
|
||||
const $desc = useTemplateRef<HTMLElement>("$desc")
|
||||
const showCode = ref(false)
|
||||
const showStats = ref(false)
|
||||
const showAssets = ref(false)
|
||||
const assets = ref<TaskAsset[]>([])
|
||||
const historyRefreshKey = ref(0)
|
||||
|
||||
const assetBaseUrl = computed(
|
||||
() => `/media/tasks/challenge/${challengeDisplay.value}/`,
|
||||
@@ -176,6 +195,7 @@ async function loadChallenge() {
|
||||
challengeDisplay.value = display
|
||||
const data = await Challenge.get(display)
|
||||
taskId.value = data.task_ptr
|
||||
challengeAuthor.value = data.author_name ?? ""
|
||||
challengeContent.value = await marked.parse(data.content, {
|
||||
async: true,
|
||||
renderer: challengeRenderer,
|
||||
@@ -194,6 +214,7 @@ async function loadChallenge() {
|
||||
},
|
||||
messageId,
|
||||
)
|
||||
historyRefreshKey.value++
|
||||
message.success("已自动提交本次对话生成的代码")
|
||||
} catch {
|
||||
// 静默失败,不打扰用户
|
||||
@@ -214,6 +235,12 @@ function clearAll() {
|
||||
js.value = ""
|
||||
}
|
||||
|
||||
function previewHistoryItem(code: { html: string; css: string; js: string }) {
|
||||
html.value = code.html
|
||||
css.value = code.css
|
||||
js.value = code.js
|
||||
}
|
||||
|
||||
function back() {
|
||||
disconnectPrompt()
|
||||
taskId.value = 0
|
||||
@@ -272,10 +299,23 @@ onUnmounted(disconnectPrompt)
|
||||
.content {
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.desc-pane {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.challenge-meta {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--n-border-color, #efeff5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.no-select {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
</n-button>
|
||||
<span>【{{ item.display }}】{{ item.title }}</span>
|
||||
<n-tag size="small" type="warning">{{ item.score }}分</n-tag>
|
||||
<n-tag size="small">出题人 {{ item.author_name || "未设置" }}</n-tag>
|
||||
</n-flex>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
@@ -66,6 +67,9 @@
|
||||
<n-form-item label="公开" label-placement="left">
|
||||
<n-switch v-model:value="challenge.is_public" />
|
||||
</n-form-item>
|
||||
<n-form-item label="出题人" label-placement="left">
|
||||
<n-text depth="3">{{ challenge.author_name || "未设置" }}</n-text>
|
||||
</n-form-item>
|
||||
<n-form-item label-placement="left">
|
||||
<n-button type="primary" @click="submit" :disabled="!canSubmit">
|
||||
提交
|
||||
@@ -107,6 +111,7 @@ const challenge = reactive({
|
||||
content: "",
|
||||
score: 0,
|
||||
is_public: false,
|
||||
author_name: "",
|
||||
})
|
||||
|
||||
const canSubmit = computed(
|
||||
@@ -129,6 +134,7 @@ function createNew() {
|
||||
challenge.content = ""
|
||||
challenge.score = 0
|
||||
challenge.is_public = false
|
||||
challenge.author_name = ""
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
@@ -140,6 +146,7 @@ async function submit() {
|
||||
challenge.content = ""
|
||||
challenge.score = 0
|
||||
challenge.is_public = false
|
||||
challenge.author_name = ""
|
||||
await getContent()
|
||||
} catch (error: any) {
|
||||
message.error(error.response.data.detail)
|
||||
@@ -168,6 +175,7 @@ async function show(display: number) {
|
||||
challenge.content = item.content
|
||||
challenge.score = item.score
|
||||
challenge.is_public = item.is_public
|
||||
challenge.author_name = item.author_name ?? ""
|
||||
}
|
||||
|
||||
async function togglePublic(display: number) {
|
||||
|
||||
@@ -43,6 +43,16 @@ const menu = computed(() =>
|
||||
route: { name: "user-manage", params: { page: 1 } },
|
||||
show: roleSuper.value,
|
||||
},
|
||||
{
|
||||
label: "工坊",
|
||||
route: { name: "showcase-manage" },
|
||||
show: roleSuper.value,
|
||||
},
|
||||
{
|
||||
label: "成绩",
|
||||
route: { name: "gradebook" },
|
||||
show: roleAdmin.value || roleSuper.value,
|
||||
},
|
||||
{
|
||||
label: "提交",
|
||||
route: { name: "submissions", params: { page: 1 } },
|
||||
|
||||
411
src/pages/Gradebook.vue
Normal file
411
src/pages/Gradebook.vue
Normal file
@@ -0,0 +1,411 @@
|
||||
<template>
|
||||
<n-flex
|
||||
vertical
|
||||
:size="12"
|
||||
style="height: 100%; min-width: 0; box-sizing: border-box; padding: 10px 10px 10px 0; overflow: hidden;"
|
||||
>
|
||||
<n-flex class="toolbar" align="center" justify="space-between" style="flex-shrink: 0;">
|
||||
<n-flex align="center" :size="8" wrap style="min-width: 0;">
|
||||
<n-select
|
||||
v-model:value="query.classname"
|
||||
class="class-select"
|
||||
:options="classOptions"
|
||||
placeholder="班级"
|
||||
:loading="classesLoading"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.task_type"
|
||||
class="type-select"
|
||||
:options="taskTypeOptions"
|
||||
/>
|
||||
<n-input
|
||||
v-model:value="query.username"
|
||||
class="search-input"
|
||||
clearable
|
||||
placeholder="学生搜索"
|
||||
/>
|
||||
<n-switch v-model:value="query.include_all_tasks">
|
||||
<template #checked>全部有提交任务</template>
|
||||
<template #unchecked>只看计入任务</template>
|
||||
</n-switch>
|
||||
</n-flex>
|
||||
<n-flex align="center" :size="8">
|
||||
<n-button
|
||||
secondary
|
||||
title="刷新"
|
||||
:disabled="!query.classname"
|
||||
:loading="loading"
|
||||
@click="loadGradebook"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:refresh-cw" :width="15" />
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
secondary
|
||||
:disabled="!query.classname || !gradebook"
|
||||
:loading="exporting"
|
||||
@click="exportCsv"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:download" :width="15" />
|
||||
</template>
|
||||
导出 CSV
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
|
||||
<n-alert v-if="loadError" type="error" closable @close="loadError = ''">
|
||||
{{ loadError }}
|
||||
</n-alert>
|
||||
|
||||
<n-flex v-if="gradebook" align="center" :size="8" style="flex-shrink: 0;">
|
||||
<n-tag size="small">学生 {{ gradebook.student_count }}</n-tag>
|
||||
<n-tag size="small">任务 {{ gradebook.task_count }}</n-tag>
|
||||
<n-tag size="small" type="success">
|
||||
计入 {{ gradebook.included_task_count }}
|
||||
</n-tag>
|
||||
<n-tag size="small">
|
||||
覆盖门槛 {{ gradebook.coverage_threshold_count }} 人
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
|
||||
<n-data-table
|
||||
size="small"
|
||||
striped
|
||||
flex-height
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:row-key="(row: GradebookRow) => row.user_id"
|
||||
:scroll-x="scrollX"
|
||||
style="flex: 1; min-height: 0;"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref, watch } from "vue"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
import {
|
||||
NButton,
|
||||
NTag,
|
||||
NText,
|
||||
useMessage,
|
||||
type DataTableColumn,
|
||||
} from "naive-ui"
|
||||
import { useRouter } from "vue-router"
|
||||
import { Account, Gradebook } from "../api"
|
||||
import { displayGradebookStudentName } from "../utils/gradebook"
|
||||
import type {
|
||||
GradebookCell,
|
||||
GradebookOut,
|
||||
GradebookQuery,
|
||||
GradebookRow,
|
||||
GradebookTask,
|
||||
GradebookTaskType,
|
||||
} from "../utils/type"
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const classesLoading = ref(false)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const loadError = ref("")
|
||||
const gradebook = ref<GradebookOut | null>(null)
|
||||
const classes = ref<string[]>([])
|
||||
|
||||
const query = reactive<GradebookQuery>({
|
||||
classname: "",
|
||||
task_type: "",
|
||||
username: "",
|
||||
include_all_tasks: false,
|
||||
})
|
||||
|
||||
const taskTypeOptions: { label: string; value: GradebookTaskType | "" }[] = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "教程", value: "tutorial" },
|
||||
{ label: "挑战", value: "challenge" },
|
||||
]
|
||||
|
||||
const classOptions = computed(() =>
|
||||
classes.value.map((classname) => ({ label: classname, value: classname })),
|
||||
)
|
||||
const rows = computed(() => gradebook.value?.rows ?? [])
|
||||
const scrollX = computed(() => 860 + (gradebook.value?.tasks.length ?? 0) * 96)
|
||||
|
||||
function formatScore(value: number | null) {
|
||||
if (value === null) return "-"
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
||||
}
|
||||
|
||||
function taskTitle(task: GradebookTask) {
|
||||
const typeLabel = task.task_type === "tutorial" ? "教程" : "挑战"
|
||||
return `${typeLabel}${task.display}`
|
||||
}
|
||||
|
||||
function openSubmission(cell: GradebookCell) {
|
||||
if (!cell.submitted || !cell.submission_id) return
|
||||
const { href } = router.resolve({
|
||||
name: "submission",
|
||||
params: { id: cell.submission_id },
|
||||
})
|
||||
window.open(href, "_blank")
|
||||
}
|
||||
|
||||
function gradeTagType(grade: string) {
|
||||
if (grade === "A") return "success"
|
||||
if (grade === "B") return "info"
|
||||
if (grade === "C") return "default"
|
||||
return "warning"
|
||||
}
|
||||
|
||||
function renderTaskHeader(task: GradebookTask) {
|
||||
return h("div", { class: ["task-header", { muted: !task.included }] }, [
|
||||
h("div", { class: "task-title", title: task.title }, taskTitle(task)),
|
||||
h("div", { class: "task-meta" }, [
|
||||
h("span", `${Math.round(task.coverage * 100)}%`),
|
||||
task.included
|
||||
? null
|
||||
: h(NTag, { size: "tiny", round: false }, { default: () => "未计入" }),
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
function renderScore(row: GradebookRow, task: GradebookTask) {
|
||||
const cell = row.scores[task.id]
|
||||
if (!cell || !cell.submitted) {
|
||||
return h(
|
||||
NText,
|
||||
{ class: "missing-cell", type: "error" },
|
||||
{ default: () => "缺交" },
|
||||
)
|
||||
}
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
type: task.included ? "primary" : "default",
|
||||
class: ["score-link", { muted: !task.included }],
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
openSubmission(cell)
|
||||
},
|
||||
},
|
||||
{ default: () => formatScore(cell.score) },
|
||||
)
|
||||
}
|
||||
|
||||
function renderMissingCount(value: number) {
|
||||
if (value <= 0) return "0"
|
||||
return h(NText, { type: "error" }, { default: () => String(value) })
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumn<GradebookRow>[]>(() => {
|
||||
const tasks = gradebook.value?.tasks ?? []
|
||||
return [
|
||||
{
|
||||
title: "排名",
|
||||
key: "rank",
|
||||
width: 60,
|
||||
fixed: "left",
|
||||
},
|
||||
{
|
||||
title: "等级",
|
||||
key: "grade",
|
||||
width: 60,
|
||||
fixed: "left",
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: "small", type: gradeTagType(row.grade) },
|
||||
{ default: () => row.grade },
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "学生",
|
||||
key: "username",
|
||||
width: 80,
|
||||
fixed: "left",
|
||||
render: (row) => {
|
||||
const studentName = displayGradebookStudentName(row)
|
||||
return h(NText, { title: studentName }, { default: () => studentName })
|
||||
},
|
||||
},
|
||||
...tasks.map((task) => ({
|
||||
title: () => renderTaskHeader(task),
|
||||
key: `task-${task.id}`,
|
||||
width: 96,
|
||||
align: "center" as const,
|
||||
className: task.included ? "" : "excluded-task-column",
|
||||
render: (row: GradebookRow) => renderScore(row, task),
|
||||
})),
|
||||
{
|
||||
title: "教程合计",
|
||||
key: "tutorial_total",
|
||||
width: 92,
|
||||
fixed: "right",
|
||||
render: (row) => formatScore(row.tutorial_total),
|
||||
},
|
||||
{
|
||||
title: "挑战合计",
|
||||
key: "challenge_total",
|
||||
width: 92,
|
||||
fixed: "right",
|
||||
render: (row) => formatScore(row.challenge_total),
|
||||
},
|
||||
{
|
||||
title: "总分",
|
||||
key: "total_score",
|
||||
width: 82,
|
||||
fixed: "right",
|
||||
render: (row) => formatScore(row.total_score),
|
||||
},
|
||||
{
|
||||
title: "平均",
|
||||
key: "average_score",
|
||||
width: 82,
|
||||
fixed: "right",
|
||||
render: (row) => formatScore(row.average_score),
|
||||
},
|
||||
{
|
||||
title: "已交",
|
||||
key: "submitted_task_count",
|
||||
width: 70,
|
||||
fixed: "right",
|
||||
},
|
||||
{
|
||||
title: "缺交",
|
||||
key: "missing_task_count",
|
||||
width: 70,
|
||||
fixed: "right",
|
||||
render: (row) => renderMissingCount(row.missing_task_count),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
async function loadClasses() {
|
||||
classesLoading.value = true
|
||||
try {
|
||||
classes.value = await Account.listClasses()
|
||||
if (!query.classname && classes.value.length > 0) {
|
||||
query.classname = classes.value[0]
|
||||
}
|
||||
} finally {
|
||||
classesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGradebook() {
|
||||
if (!query.classname) {
|
||||
gradebook.value = null
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
loadError.value = ""
|
||||
try {
|
||||
gradebook.value = await Gradebook.get(query)
|
||||
classes.value = gradebook.value.classes
|
||||
} catch (err: any) {
|
||||
loadError.value = err.response?.data?.detail ?? "成绩册加载失败"
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
if (!query.classname) return
|
||||
exporting.value = true
|
||||
try {
|
||||
await Gradebook.downloadCsv(query)
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "导出失败")
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [query.classname, query.task_type, query.include_all_tasks],
|
||||
() => loadGradebook(),
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => query.username,
|
||||
() => loadGradebook(),
|
||||
{ debounce: 400, maxWait: 1000 },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadClasses()
|
||||
await loadGradebook()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.class-select {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.type-select {
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
min-width: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.task-header.muted {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
min-height: 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.missing-cell {
|
||||
color: #d03050;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.score-link.muted {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
:deep(.excluded-task-column) {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.toolbar {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.class-select,
|
||||
.type-select,
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
161
src/pages/Showcase.vue
Normal file
161
src/pages/Showcase.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<main class="showcase">
|
||||
<n-flex justify="space-between" align="flex-end" style="margin-bottom: 32px;">
|
||||
<div>
|
||||
<n-h2 style="margin: 0 0 4px;">创意工坊</n-h2>
|
||||
<n-text depth="3">优秀作品展示</n-text>
|
||||
</div>
|
||||
</n-flex>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<n-empty
|
||||
v-if="!loading && awards.length === 0"
|
||||
description="暂无展示作品"
|
||||
style="margin-top: 72px;"
|
||||
/>
|
||||
|
||||
<section
|
||||
v-for="section in awards"
|
||||
:key="section.id"
|
||||
style="margin-bottom: 48px;"
|
||||
>
|
||||
<n-flex vertical :size="4" style="margin-bottom: 16px;">
|
||||
<n-h3 style="margin: 0;">{{ section.name }}</n-h3>
|
||||
<n-text v-if="section.description" depth="3" style="font-size: 13px;">
|
||||
{{ section.description }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
|
||||
<div class="card-grid">
|
||||
<n-card
|
||||
v-for="item in section.items"
|
||||
:key="item.submission_id"
|
||||
class="work-card"
|
||||
content-style="padding: 0;"
|
||||
hoverable
|
||||
@click="openDetail(item.submission_id)"
|
||||
>
|
||||
<div class="card-preview">
|
||||
<iframe
|
||||
:srcdoc="buildSrcdoc(item)"
|
||||
sandbox="allow-scripts"
|
||||
scrolling="no"
|
||||
class="preview-iframe"
|
||||
/>
|
||||
<div class="preview-overlay" />
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<n-flex justify="space-between" align="center" :wrap="false">
|
||||
<n-ellipsis style="font-size: 13px; font-weight: 600; min-width: 0; flex: 1;">
|
||||
{{ item.username }}
|
||||
</n-ellipsis>
|
||||
<n-flex align="center" :wrap="false" :size="8" style="flex-shrink: 0;">
|
||||
<n-flex align="center" :size="3">
|
||||
<Icon icon="lucide:star" :width="13" />
|
||||
<n-text style="font-size: 12px; color: #666;">
|
||||
{{ item.score.toFixed(1) }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
<n-flex align="center" :size="3">
|
||||
<Icon icon="lucide:eye" :width="13" />
|
||||
<n-text style="font-size: 12px; color: #666;">
|
||||
{{ item.view_count }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-ellipsis
|
||||
style="display: block; margin-top: 4px; font-size: 12px; line-height: 1.4; color: #888;"
|
||||
>
|
||||
{{ item.task_title }}
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</section>
|
||||
</n-spin>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { Showcase } from "../api"
|
||||
import type { AwardSection, ShowcaseItem } from "../utils/type"
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const awards = ref<AwardSection[]>([])
|
||||
|
||||
function buildSrcdoc(item: ShowcaseItem): string {
|
||||
const css = item.css ? `<style>${item.css}</style>` : ""
|
||||
const js = item.js ? `<script>${item.js}<\/script>` : ""
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/normalize.min.css" />${css}</head><body>${item.html ?? ""}${js}</body></html>`
|
||||
}
|
||||
|
||||
function openDetail(id: string) {
|
||||
router.push({ name: "showcase-detail", params: { id } })
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
awards.value = await Showcase.list()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(init)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.showcase {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 48px;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.work-card {
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.work-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
position: relative;
|
||||
height: 160px;
|
||||
overflow: hidden;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
border: none;
|
||||
transform: scale(0.5);
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
min-height: 72px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
294
src/pages/ShowcaseDetail.vue
Normal file
294
src/pages/ShowcaseDetail.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<main v-if="detail" class="detail-layout">
|
||||
<section class="preview-panel">
|
||||
<div class="back-bar">
|
||||
<n-button text @click="router.back()">
|
||||
<template #icon>
|
||||
<Icon icon="lucide:arrow-left" />
|
||||
</template>
|
||||
返回创意工坊
|
||||
</n-button>
|
||||
</div>
|
||||
<iframe
|
||||
v-if="detailSrcdoc"
|
||||
:srcdoc="detailSrcdoc"
|
||||
class="preview-iframe"
|
||||
sandbox="allow-scripts"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside class="info-panel">
|
||||
<n-flex vertical :size="0">
|
||||
<n-h3 style="margin: 0 0 4px;">{{ detail.task_title }}</n-h3>
|
||||
<n-text depth="3">{{ detail.username }}</n-text>
|
||||
<n-flex wrap :size="8" style="margin-top: 12px;">
|
||||
<n-tag
|
||||
v-for="award in detail.awards"
|
||||
:key="award"
|
||||
type="warning"
|
||||
size="small"
|
||||
>
|
||||
{{ award }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-flex :size="18" style="margin-top: 14px;">
|
||||
<n-flex align="center" :size="6">
|
||||
<Icon icon="lucide:star" :width="16" />
|
||||
<n-text strong style="font-size: 14px;">
|
||||
{{ detail.score.toFixed(1) }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
<n-flex align="center" :size="6">
|
||||
<Icon icon="lucide:eye" :width="16" />
|
||||
<n-text strong style="font-size: 14px;">
|
||||
{{ detail.view_count }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
|
||||
<n-divider v-if="detail.has_prompt_chain" />
|
||||
|
||||
<n-collapse
|
||||
v-if="detail.has_prompt_chain"
|
||||
@update:expanded-names="onCollapseChange"
|
||||
>
|
||||
<n-collapse-item title="创作过程" name="chain">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px;">点击展开</n-text>
|
||||
</template>
|
||||
<n-spin :show="chainLoading">
|
||||
<n-empty
|
||||
v-if="!chainLoading && rounds.length === 0"
|
||||
description="暂无记录"
|
||||
/>
|
||||
<n-flex v-else vertical :size="12">
|
||||
<n-scrollbar style="max-height: 260px;">
|
||||
<n-flex vertical :size="8" style="padding-right: 4px;">
|
||||
<n-card
|
||||
v-for="(round, i) in rounds"
|
||||
:key="i"
|
||||
size="small"
|
||||
content-style="padding: 8px;"
|
||||
:style="{
|
||||
cursor: 'pointer',
|
||||
borderColor: selectedRound === i ? '#2080f0' : undefined,
|
||||
background: selectedRound === i ? '#e8f0fe' : undefined,
|
||||
}"
|
||||
@click="selectedRound = i"
|
||||
>
|
||||
<n-flex align="flex-start" :size="8">
|
||||
<n-avatar
|
||||
round
|
||||
:size="20"
|
||||
:color="selectedRound === i ? '#2080f0' : '#9db7e8'"
|
||||
style="font-size: 11px; font-weight: 700; flex-shrink: 0;"
|
||||
>
|
||||
{{ i + 1 }}
|
||||
</n-avatar>
|
||||
<n-flex vertical :size="4" style="min-width: 0; flex: 1;">
|
||||
<n-text style="font-size: 12px; line-height: 1.5;">
|
||||
{{ round.question }}
|
||||
</n-text>
|
||||
<n-flex :size="5">
|
||||
<n-tag size="small" style="font-size: 10px;">
|
||||
{{ round.source === "conversation" ? "对话" : "手动" }}
|
||||
</n-tag>
|
||||
<n-text
|
||||
v-if="round.prompt_level"
|
||||
:style="{
|
||||
color: levelColors[round.prompt_level],
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
}"
|
||||
>
|
||||
L{{ round.prompt_level }}
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</n-scrollbar>
|
||||
|
||||
<n-flex vertical :size="8">
|
||||
<n-text strong style="font-size: 12px; color: #555;">
|
||||
第 {{ selectedRound + 1 }} 轮效果
|
||||
</n-text>
|
||||
<iframe
|
||||
v-if="selectedRoundSrcdoc"
|
||||
:key="selectedRound"
|
||||
:srcdoc="selectedRoundSrcdoc"
|
||||
sandbox="allow-scripts"
|
||||
class="round-iframe"
|
||||
/>
|
||||
<n-flex
|
||||
v-else
|
||||
justify="center"
|
||||
align="center"
|
||||
style="min-height: 240px;"
|
||||
>
|
||||
<n-empty description="该轮无网页代码" />
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-spin>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<n-flex
|
||||
v-else-if="notFound"
|
||||
justify="center"
|
||||
align="center"
|
||||
style="min-height: 100vh; padding: 40px;"
|
||||
>
|
||||
<n-empty description="作品不存在" />
|
||||
</n-flex>
|
||||
|
||||
<n-flex v-else justify="center" align="center" style="min-height: 100vh;">
|
||||
<n-spin />
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { Showcase, Submission } from "../api"
|
||||
import type { PromptRound, ShowcaseDetail } from "../utils/type"
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const detail = ref<ShowcaseDetail | null>(null)
|
||||
const notFound = ref(false)
|
||||
const rounds = ref<PromptRound[]>([])
|
||||
const chainLoading = ref(false)
|
||||
const selectedRound = ref(0)
|
||||
const chainLoaded = ref(false)
|
||||
|
||||
const levelColors: Record<number, string> = {
|
||||
1: "#888",
|
||||
2: "#4f8f7f",
|
||||
3: "#2f7bc1",
|
||||
4: "#aa5f9f",
|
||||
5: "#c48620",
|
||||
6: "#c94f4f",
|
||||
}
|
||||
|
||||
const detailSrcdoc = computed(() => {
|
||||
if (!detail.value) return null
|
||||
return buildDetailHtml(detail.value)
|
||||
})
|
||||
|
||||
const selectedRoundSrcdoc = 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"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/normalize.min.css" />${style}</head><body>${round.html}${script}</body></html>`
|
||||
})
|
||||
|
||||
function buildDetailHtml(d: ShowcaseDetail) {
|
||||
const css = d.css ? `<style>${d.css}</style>` : ""
|
||||
const js = d.js ? `<script>${d.js}<\/script>` : ""
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><link rel="stylesheet" href="/normalize.min.css" />${css}</head><body>${d.html ?? ""}${js}</body></html>`
|
||||
}
|
||||
|
||||
async function loadChain() {
|
||||
if (chainLoaded.value) return
|
||||
chainLoading.value = true
|
||||
try {
|
||||
rounds.value = await Showcase.getPromptChain(props.id)
|
||||
selectedRound.value = Math.max(0, rounds.value.length - 1)
|
||||
chainLoaded.value = true
|
||||
} finally {
|
||||
chainLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCollapseChange(
|
||||
names: string | number | Array<string | number> | null,
|
||||
) {
|
||||
const expanded = Array.isArray(names) ? names : names == null ? [] : [names]
|
||||
if (expanded.includes("chain")) void loadChain()
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
detail.value = await Showcase.getDetail(props.id)
|
||||
void Submission.incrementView(props.id)
|
||||
} catch {
|
||||
notFound.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(init)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.back-bar {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
width: 360px;
|
||||
flex-shrink: 0;
|
||||
padding: 20px 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.round-iframe {
|
||||
min-height: 240px;
|
||||
flex: 1;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.detail-layout {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
min-height: 56vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
600
src/pages/ShowcaseManage.vue
Normal file
600
src/pages/ShowcaseManage.vue
Normal file
@@ -0,0 +1,600 @@
|
||||
<template>
|
||||
<n-layout has-sider style="height: 100%;">
|
||||
<n-layout-sider
|
||||
:width="260"
|
||||
bordered
|
||||
content-style="overflow: auto; height: 100%;"
|
||||
style="background: #fafafa;"
|
||||
>
|
||||
<n-flex class="panel-header" justify="space-between" align="center">
|
||||
<n-text strong>奖项</n-text>
|
||||
<n-button size="small" secondary title="新建奖项" @click="startCreate">
|
||||
<template #icon>
|
||||
<Icon icon="lucide:plus" :width="15" />
|
||||
</template>
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-spin :show="awardsLoading">
|
||||
<n-empty
|
||||
v-if="!awardsLoading && awards.length === 0"
|
||||
description="暂无奖项"
|
||||
size="small"
|
||||
style="margin-top: 40px;"
|
||||
/>
|
||||
<button
|
||||
v-for="award in awards"
|
||||
:key="award.id"
|
||||
type="button"
|
||||
:class="[
|
||||
'award-row',
|
||||
{ active: currentAwardId === award.id && !creating },
|
||||
]"
|
||||
@click="selectAward(award)"
|
||||
>
|
||||
<n-ellipsis style="flex: 1; min-width: 0; font-size: 14px; font-weight: 500;">
|
||||
{{ award.name }}
|
||||
</n-ellipsis>
|
||||
<n-flex align="center" :size="6" style="flex-shrink: 0; color: #777; font-size: 12px;">
|
||||
<n-tag v-if="!award.is_active" size="small">停用</n-tag>
|
||||
<span>{{ award.item_count }} 件</span>
|
||||
</n-flex>
|
||||
</button>
|
||||
</n-spin>
|
||||
</n-layout-sider>
|
||||
|
||||
<n-layout content-style="padding: 12px; overflow: auto; height: 100%; box-sizing: border-box;">
|
||||
<n-form
|
||||
:model="awardDraft"
|
||||
label-placement="left"
|
||||
label-width="82"
|
||||
style="max-width: 1100px;"
|
||||
>
|
||||
<n-grid :cols="4" :x-gap="12" :y-gap="8" responsive="screen">
|
||||
<n-form-item-gi :span="2" label="名称">
|
||||
<n-input v-model:value="awardDraft.name" placeholder="奖项名称" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="排序">
|
||||
<n-input-number
|
||||
v-model:value="awardDraft.sort_order"
|
||||
:show-button="false"
|
||||
style="width: 120px;"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="启用">
|
||||
<n-switch v-model:value="awardDraft.is_active" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="2" label="简介">
|
||||
<n-input
|
||||
v-model:value="awardDraft.description"
|
||||
placeholder="可留空"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi label="作品排序">
|
||||
<n-select
|
||||
v-model:value="awardDraft.item_ordering"
|
||||
:options="orderingOptions"
|
||||
/>
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi>
|
||||
<n-flex justify="end" style="width: 100%;">
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!canSaveAward"
|
||||
:loading="savingAward"
|
||||
@click="saveAward"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:save" :width="15" />
|
||||
</template>
|
||||
保存
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="currentAwardId && !creating"
|
||||
tertiary
|
||||
type="error"
|
||||
:loading="deletingAward"
|
||||
@click="deleteCurrentAward"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:trash-2" :width="15" />
|
||||
</template>
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-form-item-gi>
|
||||
</n-grid>
|
||||
</n-form>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<n-flex justify="space-between" align="center" style="margin-bottom: 10px;">
|
||||
<n-text strong>已授奖作品</n-text>
|
||||
<n-flex align="center">
|
||||
<n-button
|
||||
size="small"
|
||||
type="primary"
|
||||
secondary
|
||||
:disabled="!currentAwardId || creating"
|
||||
@click="openAddWorkModal"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:plus" :width="15" />
|
||||
</template>
|
||||
添加作品
|
||||
</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
secondary
|
||||
title="刷新"
|
||||
:disabled="!currentAwardId || creating"
|
||||
:loading="itemsLoading"
|
||||
@click="loadAwardItems"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:refresh-cw" :width="15" />
|
||||
</template>
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
size="small"
|
||||
striped
|
||||
:columns="itemColumns"
|
||||
:data="awardItems"
|
||||
:loading="itemsLoading"
|
||||
:row-key="(row: AwardItemManageOut) => row.id"
|
||||
style="max-width: 1100px;"
|
||||
/>
|
||||
</n-layout>
|
||||
</n-layout>
|
||||
|
||||
<n-modal
|
||||
v-model:show="addWorkModalVisible"
|
||||
preset="card"
|
||||
title="添加作品"
|
||||
style="width: min(640px, calc(100vw - 32px));"
|
||||
>
|
||||
<n-flex vertical :size="12">
|
||||
<n-input-group>
|
||||
<n-input
|
||||
v-model:value="lookupSubmissionId"
|
||||
clearable
|
||||
placeholder="提交 ID"
|
||||
@keyup.enter="findSubmissionForAward"
|
||||
/>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!lookupSubmissionId.trim()"
|
||||
:loading="lookupLoading"
|
||||
@click="findSubmissionForAward"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:search" :width="15" />
|
||||
</template>
|
||||
查找
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
|
||||
<n-alert v-if="lookupError" type="error">
|
||||
{{ lookupError }}
|
||||
</n-alert>
|
||||
|
||||
<n-flex v-if="submissionCandidate" vertical :size="12">
|
||||
<n-descriptions :column="2" size="small" bordered>
|
||||
<n-descriptions-item label="提交者">
|
||||
{{ submissionCandidate.username }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="任务">
|
||||
{{ submissionCandidate.task_title }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="得分">
|
||||
{{
|
||||
submissionCandidate.score > 0
|
||||
? submissionCandidate.score.toFixed(2)
|
||||
: "-"
|
||||
}}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="浏览">
|
||||
{{ submissionCandidate.view_count }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="提示词">
|
||||
<n-tag
|
||||
size="small"
|
||||
:type="
|
||||
submissionCandidate.has_prompt_chain ? 'success' : 'default'
|
||||
"
|
||||
>
|
||||
{{ submissionCandidate.has_prompt_chain ? "有" : "无" }}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">
|
||||
<n-tag
|
||||
size="small"
|
||||
:type="candidateAlreadyAwarded ? 'default' : 'info'"
|
||||
>
|
||||
{{ candidateAlreadyAwarded ? "已添加" : "可添加" }}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-flex justify="end" style="width: 100%;">
|
||||
<n-button secondary @click="clearSubmissionLookup">清空</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="candidateAlreadyAwarded"
|
||||
:loading="addingCandidate"
|
||||
@click="addCandidateToAward"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon
|
||||
:icon="candidateAlreadyAwarded ? 'lucide:check' : 'lucide:plus'"
|
||||
:width="15"
|
||||
/>
|
||||
</template>
|
||||
{{ candidateAlreadyAwarded ? "已添加" : "添加到奖项" }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref } from "vue"
|
||||
import {
|
||||
NButton,
|
||||
NInputNumber,
|
||||
NTag,
|
||||
useMessage,
|
||||
type DataTableColumn,
|
||||
} from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { Showcase } from "../api"
|
||||
import type {
|
||||
AwardItemManageOut,
|
||||
AwardManageIn,
|
||||
AwardManageOut,
|
||||
ItemOrdering,
|
||||
ShowcaseSubmissionLookupOut,
|
||||
} from "../utils/type"
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const awards = ref<AwardManageOut[]>([])
|
||||
const awardItems = ref<AwardItemManageOut[]>([])
|
||||
const currentAwardId = ref<number | null>(null)
|
||||
const creating = ref(false)
|
||||
const awardsLoading = ref(false)
|
||||
const itemsLoading = ref(false)
|
||||
const savingAward = ref(false)
|
||||
const deletingAward = ref(false)
|
||||
const updatingItemIds = ref(new Set<number>())
|
||||
const addWorkModalVisible = ref(false)
|
||||
const lookupSubmissionId = ref("")
|
||||
const lookupLoading = ref(false)
|
||||
const lookupError = ref("")
|
||||
const submissionCandidate = ref<ShowcaseSubmissionLookupOut | null>(null)
|
||||
const addingCandidate = ref(false)
|
||||
|
||||
const awardDraft = reactive<AwardManageIn>(defaultAward())
|
||||
|
||||
const orderingOptions: { label: string; value: ItemOrdering }[] = [
|
||||
{ label: "手动排序", value: "manual" },
|
||||
{ label: "授奖时间", value: "awarded_at" },
|
||||
{ label: "评分", value: "score" },
|
||||
{ label: "浏览量", value: "view_count" },
|
||||
]
|
||||
|
||||
const canSaveAward = computed(() => awardDraft.name.trim().length > 0)
|
||||
const awardedSubmissionIds = computed(
|
||||
() => new Set(awardItems.value.map((item) => item.submission_id)),
|
||||
)
|
||||
const candidateAlreadyAwarded = computed(
|
||||
() =>
|
||||
!!submissionCandidate.value &&
|
||||
awardedSubmissionIds.value.has(submissionCandidate.value.submission_id),
|
||||
)
|
||||
const nextSortOrder = computed(() => {
|
||||
if (awardItems.value.length === 0) return 0
|
||||
return Math.max(...awardItems.value.map((item) => item.sort_order)) + 1
|
||||
})
|
||||
|
||||
const itemColumns: DataTableColumn<AwardItemManageOut>[] = [
|
||||
{
|
||||
title: "排序",
|
||||
key: "sort_order",
|
||||
width: 92,
|
||||
render: (row) =>
|
||||
h(NInputNumber, {
|
||||
value: row.sort_order,
|
||||
min: 0,
|
||||
size: "small",
|
||||
showButton: false,
|
||||
class: "table-number-input",
|
||||
loading: updatingItemIds.value.has(row.id),
|
||||
"onUpdate:value": (value: number | null) =>
|
||||
updateItemOrder(row, value ?? 0),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "提交者",
|
||||
key: "username",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "任务",
|
||||
key: "task_title",
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
title: "得分",
|
||||
key: "score",
|
||||
width: 72,
|
||||
render: (row) => (row.score > 0 ? row.score.toFixed(2) : "-"),
|
||||
},
|
||||
{
|
||||
title: "浏览",
|
||||
key: "view_count",
|
||||
width: 72,
|
||||
},
|
||||
{
|
||||
title: "提示词",
|
||||
key: "has_prompt_chain",
|
||||
width: 88,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: "small", type: row.has_prompt_chain ? "success" : "default" },
|
||||
{ default: () => (row.has_prompt_chain ? "有" : "无") },
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "actions",
|
||||
width: 54,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
tertiary: true,
|
||||
type: "error",
|
||||
title: "移除",
|
||||
onClick: () => removeAwardItem(row),
|
||||
},
|
||||
{ icon: () => h(Icon, { icon: "lucide:trash-2", width: 15 }) },
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function defaultAward(): AwardManageIn {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
item_ordering: "manual",
|
||||
}
|
||||
}
|
||||
|
||||
function assignDraft(award: AwardManageIn) {
|
||||
Object.assign(awardDraft, {
|
||||
name: award.name,
|
||||
description: award.description,
|
||||
sort_order: award.sort_order,
|
||||
is_active: award.is_active,
|
||||
item_ordering: award.item_ordering,
|
||||
})
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
currentAwardId.value = null
|
||||
creating.value = true
|
||||
awardItems.value = []
|
||||
assignDraft(defaultAward())
|
||||
}
|
||||
|
||||
async function loadAwards() {
|
||||
awardsLoading.value = true
|
||||
try {
|
||||
awards.value = await Showcase.listManageAwards()
|
||||
} finally {
|
||||
awardsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectAward(award: AwardManageOut) {
|
||||
currentAwardId.value = award.id
|
||||
creating.value = false
|
||||
assignDraft(award)
|
||||
await loadAwardItems()
|
||||
}
|
||||
|
||||
async function saveAward() {
|
||||
if (!canSaveAward.value) return
|
||||
const payload: AwardManageIn = {
|
||||
...awardDraft,
|
||||
name: awardDraft.name.trim(),
|
||||
description: awardDraft.description.trim(),
|
||||
}
|
||||
savingAward.value = true
|
||||
try {
|
||||
const saved =
|
||||
currentAwardId.value && !creating.value
|
||||
? await Showcase.updateAward(currentAwardId.value, payload)
|
||||
: await Showcase.createAward(payload)
|
||||
await loadAwards()
|
||||
const next = awards.value.find((award) => award.id === saved.id)
|
||||
if (next) await selectAward(next)
|
||||
message.success("已保存")
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "保存失败")
|
||||
} finally {
|
||||
savingAward.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrentAward() {
|
||||
if (!currentAwardId.value || creating.value) return
|
||||
if (!window.confirm("确定删除这个奖项?")) return
|
||||
deletingAward.value = true
|
||||
try {
|
||||
await Showcase.deleteAward(currentAwardId.value)
|
||||
await loadAwards()
|
||||
if (awards.value.length > 0) await selectAward(awards.value[0])
|
||||
else startCreate()
|
||||
message.success("已删除")
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "删除失败")
|
||||
} finally {
|
||||
deletingAward.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAwardItems() {
|
||||
if (!currentAwardId.value || creating.value) {
|
||||
awardItems.value = []
|
||||
return
|
||||
}
|
||||
itemsLoading.value = true
|
||||
try {
|
||||
awardItems.value = await Showcase.listAwardItems(currentAwardId.value)
|
||||
} finally {
|
||||
itemsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setUpdatingItem(id: number, loading: boolean) {
|
||||
const next = new Set(updatingItemIds.value)
|
||||
if (loading) next.add(id)
|
||||
else next.delete(id)
|
||||
updatingItemIds.value = next
|
||||
}
|
||||
|
||||
async function updateItemOrder(row: AwardItemManageOut, sortOrder: number) {
|
||||
if (row.sort_order === sortOrder) return
|
||||
row.sort_order = sortOrder
|
||||
setUpdatingItem(row.id, true)
|
||||
try {
|
||||
await Showcase.updateAwardItem(row.id, { sort_order: sortOrder })
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "排序更新失败")
|
||||
await loadAwardItems()
|
||||
} finally {
|
||||
setUpdatingItem(row.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAwardItem(row: AwardItemManageOut) {
|
||||
if (!window.confirm("确定移除这个作品?")) return
|
||||
try {
|
||||
await Showcase.deleteAwardItem(row.id)
|
||||
awardItems.value = awardItems.value.filter((item) => item.id !== row.id)
|
||||
await loadAwards()
|
||||
message.success("已移除")
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "移除失败")
|
||||
}
|
||||
}
|
||||
|
||||
function clearSubmissionLookup() {
|
||||
lookupSubmissionId.value = ""
|
||||
lookupError.value = ""
|
||||
submissionCandidate.value = null
|
||||
}
|
||||
|
||||
function openAddWorkModal() {
|
||||
if (!currentAwardId.value || creating.value) return
|
||||
clearSubmissionLookup()
|
||||
addWorkModalVisible.value = true
|
||||
}
|
||||
|
||||
async function findSubmissionForAward() {
|
||||
const submissionId = lookupSubmissionId.value.trim()
|
||||
if (!submissionId) return
|
||||
lookupLoading.value = true
|
||||
lookupError.value = ""
|
||||
submissionCandidate.value = null
|
||||
try {
|
||||
submissionCandidate.value =
|
||||
await Showcase.findSubmissionForAward(submissionId)
|
||||
} catch (err: any) {
|
||||
lookupError.value = err.response?.data?.detail ?? "没有找到这个提交"
|
||||
} finally {
|
||||
lookupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addCandidateToAward() {
|
||||
const candidate = submissionCandidate.value
|
||||
if (
|
||||
!currentAwardId.value ||
|
||||
creating.value ||
|
||||
!candidate ||
|
||||
candidateAlreadyAwarded.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
addingCandidate.value = true
|
||||
try {
|
||||
const item = await Showcase.addAwardItem(currentAwardId.value, {
|
||||
submission_id: candidate.submission_id,
|
||||
sort_order: nextSortOrder.value,
|
||||
})
|
||||
awardItems.value = [...awardItems.value, item]
|
||||
await loadAwards()
|
||||
message.success("已添加作品")
|
||||
addWorkModalVisible.value = false
|
||||
clearSubmissionLookup()
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "添加作品失败")
|
||||
} finally {
|
||||
addingCandidate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAwards()
|
||||
if (awards.value.length > 0) await selectAward(awards.value[0])
|
||||
else startCreate()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.panel-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #efeff5;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.award-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: transparent;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.award-row:hover {
|
||||
background: #f2f5f8;
|
||||
}
|
||||
|
||||
.award-row.active {
|
||||
background: #e8f8f0;
|
||||
color: #18a058;
|
||||
}
|
||||
|
||||
:deep(.table-number-input) {
|
||||
width: 76px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMounted, useTemplateRef } from "vue"
|
||||
import { Submission } from "../api"
|
||||
import type { SubmissionAll } from "../utils/type"
|
||||
import { buildPreviewDocument } from "../utils/previewDocument"
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
@@ -18,20 +19,13 @@ async function init() {
|
||||
const doc = iframe.value.contentDocument
|
||||
if (doc) {
|
||||
doc.open()
|
||||
doc.write(`<!DOCTYPE html>
|
||||
<html lang="zh-Hans-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>预览</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>${submission.css}</style>
|
||||
<link rel="stylesheet" href="/normalize.min.css" />
|
||||
</head>
|
||||
<body>
|
||||
${submission.html}
|
||||
<script>${submission.js}<\/script>
|
||||
</body>
|
||||
</html>`)
|
||||
doc.write(
|
||||
buildPreviewDocument({
|
||||
html: submission.html,
|
||||
css: submission.css,
|
||||
js: submission.js,
|
||||
}),
|
||||
)
|
||||
doc.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,8 +105,7 @@
|
||||
|
||||
<ChainModal
|
||||
v-model:show="chainModal"
|
||||
:user-id="chainUserId"
|
||||
:task-id="chainTaskId"
|
||||
:submission-id="chainSubmissionId"
|
||||
:username="chainUsername"
|
||||
/>
|
||||
</template>
|
||||
@@ -159,8 +158,7 @@ const js = computed(() => submission.value.js)
|
||||
// Modal 状态
|
||||
const codeModal = ref(false)
|
||||
const chainModal = ref(false)
|
||||
const chainUserId = ref<number>(0)
|
||||
const chainTaskId = ref<number>(0)
|
||||
const chainSubmissionId = ref<string>("")
|
||||
const chainUsername = ref<string>("")
|
||||
|
||||
// 展开行
|
||||
@@ -203,9 +201,8 @@ async function clearAllFlags() {
|
||||
query.flag = null
|
||||
}
|
||||
|
||||
function showChain(userId: number, taskId: number, username: string) {
|
||||
chainUserId.value = userId
|
||||
chainTaskId.value = taskId
|
||||
function showChain(submissionId: string, username: string) {
|
||||
chainSubmissionId.value = submissionId
|
||||
chainUsername.value = username
|
||||
chainModal.value = true
|
||||
}
|
||||
@@ -222,7 +219,8 @@ const columns: DataTableColumn<SubmissionOut>[] = [
|
||||
loading: expandedLoading.has(row.id),
|
||||
onSelect: (id) => getSubmissionByID(id),
|
||||
onDelete: (r, parentId) => handleDelete(r, parentId),
|
||||
"onShow-chain": (userId, taskId, username) => showChain(userId, taskId, username),
|
||||
"onShow-chain": (submissionId, username) =>
|
||||
showChain(submissionId, username),
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,6 +25,19 @@ const routes = [
|
||||
component: () => import("./pages/Submission.vue"),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: "/showcase",
|
||||
name: "showcase",
|
||||
component: () => import("./pages/Showcase.vue"),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: "/showcase/:id",
|
||||
name: "showcase-detail",
|
||||
component: () => import("./pages/ShowcaseDetail.vue"),
|
||||
props: true,
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: "/dashboard",
|
||||
name: "dashboard",
|
||||
@@ -46,6 +59,16 @@ const routes = [
|
||||
name: "user-manage",
|
||||
component: () => import("./pages/UserManage.vue"),
|
||||
},
|
||||
{
|
||||
path: "showcase",
|
||||
name: "showcase-manage",
|
||||
component: () => import("./pages/ShowcaseManage.vue"),
|
||||
},
|
||||
{
|
||||
path: "gradebook",
|
||||
name: "gradebook",
|
||||
component: () => import("./pages/Gradebook.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -55,12 +78,10 @@ export const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
router.beforeEach((to) => {
|
||||
const isLoggedIn = localStorage.getItem(STORAGE_KEY.LOGIN) === "true"
|
||||
if (to.meta.auth && !isLoggedIn) {
|
||||
loginModal.value = true
|
||||
next(false)
|
||||
} else {
|
||||
next()
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
12
src/utils/gradebook.ts
Normal file
12
src/utils/gradebook.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface GradebookStudentIdentity {
|
||||
username: string
|
||||
classname: string
|
||||
}
|
||||
|
||||
export function displayGradebookStudentName(student: GradebookStudentIdentity) {
|
||||
const generatedPrefix = `web${student.classname}`
|
||||
if (!student.classname || !student.username.startsWith(generatedPrefix)) {
|
||||
return student.username
|
||||
}
|
||||
return student.username.slice(generatedPrefix.length)
|
||||
}
|
||||
29
src/utils/previewDocument.ts
Normal file
29
src/utils/previewDocument.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
interface PreviewDocumentOptions {
|
||||
html: string
|
||||
css: string
|
||||
js: string
|
||||
assetBaseUrl?: string
|
||||
}
|
||||
|
||||
export function buildPreviewDocument({
|
||||
html,
|
||||
css,
|
||||
js,
|
||||
assetBaseUrl,
|
||||
}: PreviewDocumentOptions) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-Hans-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>预览</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
${assetBaseUrl ? `<base href="${assetBaseUrl}">` : ""}
|
||||
<style>${css}</style>
|
||||
<link rel="stylesheet" href="/normalize.min.css" />
|
||||
</head>
|
||||
<body>
|
||||
${html}
|
||||
<script>${js}<\/script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
@@ -12,6 +12,19 @@ export interface PromptMessage {
|
||||
created: string
|
||||
}
|
||||
|
||||
export interface PromptHistoryItem {
|
||||
user_message_id: number
|
||||
assistant_message_id: number
|
||||
submission_id: string | null
|
||||
source: string
|
||||
prompt: string
|
||||
prompt_level: number | null
|
||||
code_html: string | null
|
||||
code_css: string | null
|
||||
code_js: string | null
|
||||
created: string
|
||||
}
|
||||
|
||||
export enum Role {
|
||||
Super = "super",
|
||||
Admin = "admin",
|
||||
@@ -56,6 +69,7 @@ export interface ChallengeSlim {
|
||||
pass_score: number | null
|
||||
submitted: boolean
|
||||
is_public: boolean
|
||||
author_name: string | null
|
||||
}
|
||||
|
||||
export interface ChallengeIn {
|
||||
@@ -160,3 +174,147 @@ export interface TaskStatsOut {
|
||||
classes: string[]
|
||||
top_viewed: TopViewedItem[]
|
||||
}
|
||||
|
||||
export type GradebookTaskType = "tutorial" | "challenge"
|
||||
export type GradebookGrade = "A" | "B" | "C" | "D" | "E"
|
||||
|
||||
export interface GradebookQuery {
|
||||
classname: string
|
||||
task_type?: GradebookTaskType | ""
|
||||
username?: string
|
||||
include_all_tasks?: boolean
|
||||
}
|
||||
|
||||
export interface GradebookTask {
|
||||
id: number
|
||||
display: number
|
||||
title: string
|
||||
task_type: GradebookTaskType
|
||||
submitted_count: number
|
||||
coverage: number
|
||||
included: boolean
|
||||
}
|
||||
|
||||
export interface GradebookCell {
|
||||
score: number
|
||||
submitted: boolean
|
||||
submission_id: string | null
|
||||
}
|
||||
|
||||
export interface GradebookRow {
|
||||
user_id: number
|
||||
username: string
|
||||
classname: string
|
||||
rank: number
|
||||
grade: GradebookGrade
|
||||
scores: Record<number, GradebookCell>
|
||||
tutorial_total: number
|
||||
challenge_total: number
|
||||
total_score: number
|
||||
average_score: number | null
|
||||
submitted_task_count: number
|
||||
missing_task_count: number
|
||||
}
|
||||
|
||||
export interface GradebookOut {
|
||||
classname: string
|
||||
classes: string[]
|
||||
task_count: number
|
||||
included_task_count: number
|
||||
student_count: number
|
||||
coverage_threshold_count: number
|
||||
tasks: GradebookTask[]
|
||||
rows: GradebookRow[]
|
||||
}
|
||||
|
||||
export interface ShowcaseItem {
|
||||
submission_id: string
|
||||
username: string
|
||||
task_title: string
|
||||
task_display: number
|
||||
score: number
|
||||
view_count: number
|
||||
html: string | null
|
||||
css: string | null
|
||||
js: string | null
|
||||
has_prompt_chain: boolean
|
||||
}
|
||||
|
||||
export interface AwardSection {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
item_ordering: string
|
||||
items: ShowcaseItem[]
|
||||
}
|
||||
|
||||
export type ItemOrdering = "manual" | "awarded_at" | "score" | "view_count"
|
||||
|
||||
export interface AwardManageIn {
|
||||
name: string
|
||||
description: string
|
||||
sort_order: number
|
||||
is_active: boolean
|
||||
item_ordering: ItemOrdering
|
||||
}
|
||||
|
||||
export interface AwardManageOut extends AwardManageIn {
|
||||
id: number
|
||||
item_count: number
|
||||
}
|
||||
|
||||
export interface AwardItemIn {
|
||||
submission_id: string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface AwardItemUpdateIn {
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface ShowcaseSubmissionLookupOut {
|
||||
submission_id: string
|
||||
username: string
|
||||
task_title: string
|
||||
task_display: number
|
||||
score: number
|
||||
view_count: number
|
||||
has_prompt_chain: boolean
|
||||
}
|
||||
|
||||
export interface AwardItemManageOut {
|
||||
id: number
|
||||
submission_id: string
|
||||
username: string
|
||||
task_title: string
|
||||
task_display: number
|
||||
score: number
|
||||
view_count: number
|
||||
sort_order: number
|
||||
awarded_at: string
|
||||
has_prompt_chain: boolean
|
||||
}
|
||||
|
||||
export interface ShowcaseDetail {
|
||||
submission_id: string
|
||||
username: string
|
||||
task_title: string
|
||||
task_display: number
|
||||
score: number
|
||||
view_count: number
|
||||
html: string | null
|
||||
css: string | null
|
||||
js: string | null
|
||||
awards: string[]
|
||||
has_prompt_chain: boolean
|
||||
}
|
||||
|
||||
export interface PromptRound {
|
||||
question: string
|
||||
source: string
|
||||
prompt_level: number | null
|
||||
assistant_msg_id?: number | null
|
||||
html: string | null
|
||||
css: string | null
|
||||
js: string | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user