chore: 将前端移动到 client/ 目录,准备合并为单仓库
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1f6935e31e
commit
d1c6b8c201
79 files changed
No files matched your search
@@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<div class="challenge-layout">
|
||||
<div class="challenge-sider">
|
||||
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
||||
<template #prefix>
|
||||
<n-button text @click="back" style="margin: 0 8px">
|
||||
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
|
||||
</n-button>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<n-flex style="margin: 0 8px">
|
||||
<n-button
|
||||
v-if="roleAdmin || roleSuper"
|
||||
text
|
||||
@click="showStats = true"
|
||||
>
|
||||
<Icon :width="16" icon="lucide:bar-chart-2" />
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="authed"
|
||||
text
|
||||
@click="
|
||||
$router.push({ name: 'submissions', params: { page: 1 } })
|
||||
"
|
||||
>
|
||||
<Icon :width="16" icon="lucide:list" />
|
||||
</n-button>
|
||||
<n-button v-if="roleSuper" text @click="edit">
|
||||
<Icon :width="16" icon="lucide:edit" />
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
|
||||
<div class="desc-pane">
|
||||
<div class="challenge-meta">
|
||||
<n-flex align="center" justify="space-between">
|
||||
<n-text depth="3">
|
||||
出题人:{{ challengeAuthor || "未设置" }}
|
||||
</n-text>
|
||||
<n-flex align="center" size="small">
|
||||
<n-button
|
||||
v-if="assets.length"
|
||||
size="small"
|
||||
@click="showAssets = true"
|
||||
>
|
||||
看素材
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="exampleCode"
|
||||
size="small"
|
||||
@click="previewExample"
|
||||
>
|
||||
看示例
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</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
|
||||
@deleted="historyRefreshKey++"
|
||||
@submitted="historyRefreshKey++"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="external" tab="手动提交" display-directive="show">
|
||||
<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"
|
||||
@deleted="removeMessagePair"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
<div class="challenge-content">
|
||||
<Preview
|
||||
:html="html"
|
||||
:css="css"
|
||||
:js="js"
|
||||
:asset-base-url="assetBaseUrl"
|
||||
show-code-button
|
||||
clearable
|
||||
@showCode="showCode = true"
|
||||
@clear="clearAll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TaskStatsModal v-model:show="showStats" :task-id="taskId" />
|
||||
<RandomRatingModal v-if="authed" />
|
||||
<n-modal
|
||||
v-model:show="showAssets"
|
||||
preset="card"
|
||||
title="素材"
|
||||
style="width: 500px"
|
||||
>
|
||||
<n-grid :cols="3" :x-gap="12" :y-gap="12">
|
||||
<n-gi v-for="asset in assets" :key="asset.name">
|
||||
<n-card size="small" :title="asset.name">
|
||||
<n-image
|
||||
:src="asset.url"
|
||||
style="width: 100%; height: 100px; object-fit: contain"
|
||||
/>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-modal>
|
||||
<n-modal
|
||||
v-model:show="showCode"
|
||||
preset="card"
|
||||
title="代码"
|
||||
style="width: 700px"
|
||||
>
|
||||
<n-tabs type="line">
|
||||
<n-tab-pane name="html" tab="HTML">
|
||||
<n-code :code="html" language="html" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="css" tab="CSS">
|
||||
<n-code :code="css" language="css" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="js" tab="JS">
|
||||
<n-code :code="js" language="javascript" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted, computed, useTemplateRef } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
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 RandomRatingModal from "../components/ai/RandomRatingModal.vue"
|
||||
import { Challenge, Submission, TaskAssets } from "../api"
|
||||
import type { TaskAsset } from "../utils/type"
|
||||
import { html, css, js } from "../store/editors"
|
||||
import { taskId, taskTab, challengeDisplay } from "../store/task"
|
||||
import { TASK_TYPE } from "../utils/const"
|
||||
import { authed, roleAdmin, roleSuper } from "../store/user"
|
||||
import {
|
||||
connectPrompt,
|
||||
disconnectPrompt,
|
||||
streaming,
|
||||
setOnCodeComplete,
|
||||
removeMessagePair,
|
||||
markMessageSubmitted,
|
||||
} from "../store/prompt"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const challengeRenderer = new marked.Renderer()
|
||||
challengeRenderer.code = ({ text, lang }) => {
|
||||
const language = lang?.toLowerCase() ?? "text"
|
||||
return `<div class="codeblock-wrapper" data-lang="${language}">
|
||||
<div class="codeblock-action">
|
||||
<span class="lang">${language.toUpperCase()}</span>
|
||||
<button class="action-btn" data-action="copy">复制</button>
|
||||
</div>
|
||||
<pre><code class="language-${language}">${text}</code></pre>
|
||||
</div>`
|
||||
}
|
||||
challengeRenderer.link = ({ href, text }) =>
|
||||
`<a href="${href}" target="_blank">${text}</a>`
|
||||
|
||||
function setupCodeCopy() {
|
||||
$desc.value?.addEventListener("click", (e: MouseEvent) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[data-action='copy']")
|
||||
if (!btn) return
|
||||
const wrapper = btn.closest<HTMLElement>("[data-lang]")!
|
||||
const code = wrapper.querySelector("code")?.textContent ?? ""
|
||||
copyFn(code)
|
||||
btn.textContent = "已复制"
|
||||
setTimeout(() => { btn.textContent = "复制" }, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
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 exampleCode = ref<{ html: string; css: string; js: string } | null>(null)
|
||||
|
||||
const assetBaseUrl = computed(
|
||||
() => `/media/tasks/challenge/${challengeDisplay.value}/`,
|
||||
)
|
||||
|
||||
watch(streaming, (val) => {
|
||||
if (val) activeTab.value = "chat"
|
||||
})
|
||||
|
||||
async function loadChallenge() {
|
||||
const display = Number(route.params.display)
|
||||
taskTab.value = TASK_TYPE.Challenge
|
||||
challengeDisplay.value = display
|
||||
const [data, fetchedAssets] = await Promise.all([
|
||||
Challenge.get(display),
|
||||
TaskAssets.listChallenge(display),
|
||||
])
|
||||
taskId.value = data.task_ptr
|
||||
challengeAuthor.value = data.author_name ?? ""
|
||||
if (data.example_html || data.example_css || data.example_js) {
|
||||
exampleCode.value = {
|
||||
html: data.example_html ?? "",
|
||||
css: data.example_css ?? "",
|
||||
js: data.example_js ?? "",
|
||||
}
|
||||
} else {
|
||||
exampleCode.value = null
|
||||
}
|
||||
challengeContent.value = await marked.parse(data.content, {
|
||||
async: true,
|
||||
renderer: challengeRenderer,
|
||||
} as MarkedOptions)
|
||||
assets.value = fetchedAssets
|
||||
if (!authed.value) return
|
||||
connectPrompt(data.task_ptr)
|
||||
setOnCodeComplete(async (code, messageId) => {
|
||||
try {
|
||||
await Submission.create(
|
||||
taskId.value,
|
||||
{
|
||||
html: code.html ?? "",
|
||||
css: code.css ?? "",
|
||||
js: code.js ?? "",
|
||||
},
|
||||
messageId,
|
||||
)
|
||||
markMessageSubmitted(messageId)
|
||||
historyRefreshKey.value++
|
||||
message.success("已自动提交本次对话生成的代码")
|
||||
} catch {
|
||||
message.error("自动提交失败,请稍后重试")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function edit() {
|
||||
router.push({
|
||||
name: "challenge-editor",
|
||||
params: { display: route.params.display },
|
||||
})
|
||||
}
|
||||
|
||||
function previewExample() {
|
||||
if (!exampleCode.value) return
|
||||
html.value = exampleCode.value.html
|
||||
css.value = exampleCode.value.css
|
||||
js.value = exampleCode.value.js
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
html.value = ""
|
||||
css.value = ""
|
||||
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
|
||||
router.push({ name: "home-challenge-list" })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupCodeCopy()
|
||||
loadChallenge()
|
||||
})
|
||||
onUnmounted(disconnectPrompt)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.challenge-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.challenge-sider {
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--n-border-color, #efeff5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.challenge-content {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-tabs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.left-tabs :deep(.n-tabs-pane-wrapper) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.left-tabs :deep(.n-tab-pane) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.challenge-sider .codeblock-wrapper {
|
||||
padding: 1rem;
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.challenge-sider .codeblock-wrapper pre {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
margin-bottom: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.challenge-sider .codeblock-wrapper pre code {
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
font-family: Monaco, monospace;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.challenge-sider .codeblock-action {
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.challenge-sider .codeblock-action .lang {
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.challenge-sider .action-btn {
|
||||
height: 28px;
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #d9d9d9;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.challenge-sider .action-btn:hover {
|
||||
border-color: #18a058;
|
||||
color: #18a058;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<n-grid x-gap="10" :cols="8">
|
||||
<n-gi :span="2" class="col">
|
||||
<n-flex vertical class="list">
|
||||
<n-card
|
||||
v-for="item in list"
|
||||
:key="item.display"
|
||||
@click="show(item.display)"
|
||||
class="card"
|
||||
:header-style="{
|
||||
backgroundColor:
|
||||
item.display === challenge.display
|
||||
? 'rgba(24, 160, 80, 0.1)'
|
||||
: '',
|
||||
}"
|
||||
:embedded="!item.is_public"
|
||||
>
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<n-button text @click.stop="togglePublic(item.display)">
|
||||
<Icon
|
||||
width="24"
|
||||
:icon="
|
||||
item.is_public
|
||||
? 'twemoji:check-mark-button'
|
||||
: 'twemoji:locked'
|
||||
"
|
||||
></Icon>
|
||||
</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>
|
||||
<n-button text type="default" @click="remove(item.display)">
|
||||
<Icon width="20" icon="material-symbols:close-rounded"></Icon>
|
||||
</n-button>
|
||||
</template>
|
||||
</n-card>
|
||||
<n-button @click="createNew">新建</n-button>
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
|
||||
<n-gi :span="6" class="col">
|
||||
<n-flex vertical>
|
||||
<n-form inline :show-feedback="false">
|
||||
<n-form-item label="序号" label-placement="left">
|
||||
<n-input-number
|
||||
style="width: 100px"
|
||||
v-model:value="challenge.display"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="标题" label-placement="left">
|
||||
<n-input v-model:value="challenge.title" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="分数" label-placement="left">
|
||||
<n-input-number
|
||||
style="width: 100px"
|
||||
v-model:value="challenge.score"
|
||||
:min="0"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<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">
|
||||
提交
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-flex>
|
||||
<TaskAssetManager
|
||||
v-if="challenge.display"
|
||||
task-type="challenge"
|
||||
:display="challenge.display"
|
||||
/>
|
||||
<n-button
|
||||
v-if="challenge.display"
|
||||
size="small"
|
||||
@click="showExampleModal = true"
|
||||
>
|
||||
示例代码
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<MarkdownEditor
|
||||
style="height: calc(100vh - 100px)"
|
||||
v-model="challenge.content"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showExampleModal"
|
||||
preset="card"
|
||||
title="示例代码(点击「看示例」时显示效果)"
|
||||
style="width: 640px"
|
||||
>
|
||||
<n-input
|
||||
type="textarea"
|
||||
v-model:value="rawCode"
|
||||
placeholder="粘贴完整的前端代码,自动拆分为 HTML / CSS / JS..."
|
||||
:autosize="{ minRows: 10, maxRows: 30 }"
|
||||
/>
|
||||
<n-flex v-if="splitResult" style="margin-top: 8px">
|
||||
<n-tag size="small" type="success">HTML · {{ splitResult.html.length }} 字符</n-tag>
|
||||
<n-tag size="small" type="info">CSS · {{ splitResult.css.length }} 字符</n-tag>
|
||||
<n-tag size="small" type="warning">JS · {{ splitResult.js.length }} 字符</n-tag>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { Challenge } from "../api"
|
||||
import type { ChallengeSlim } from "../utils/type"
|
||||
import { useDialog, useMessage } from "naive-ui"
|
||||
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
|
||||
import TaskAssetManager from "../components/task/TaskAssetManager.vue"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const confirm = useDialog()
|
||||
|
||||
const list = ref<ChallengeSlim[]>([])
|
||||
const showExampleModal = ref(false)
|
||||
const rawCode = ref("")
|
||||
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
|
||||
|
||||
function splitHtml(raw: string) {
|
||||
let result = raw
|
||||
const cssBlocks: string[] = []
|
||||
const jsBlocks: string[] = []
|
||||
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, c) => {
|
||||
cssBlocks.push(c.trim())
|
||||
return ""
|
||||
})
|
||||
result = result.replace(
|
||||
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
|
||||
(_, c) => {
|
||||
jsBlocks.push(c.trim())
|
||||
return ""
|
||||
},
|
||||
)
|
||||
return {
|
||||
html: result.trim(),
|
||||
css: cssBlocks.join("\n\n"),
|
||||
js: jsBlocks.join("\n\n"),
|
||||
}
|
||||
}
|
||||
|
||||
watch(rawCode, (val) => {
|
||||
if (!val.trim()) {
|
||||
splitResult.value = null
|
||||
challenge.example_html = null
|
||||
challenge.example_css = null
|
||||
challenge.example_js = null
|
||||
return
|
||||
}
|
||||
const split = splitHtml(val)
|
||||
splitResult.value = split
|
||||
challenge.example_html = split.html || null
|
||||
challenge.example_css = split.css || null
|
||||
challenge.example_js = split.js || null
|
||||
})
|
||||
|
||||
watch(showExampleModal, (visible) => {
|
||||
if (!visible) return
|
||||
const parts: string[] = []
|
||||
if (challenge.example_css) parts.push(`<style>\n${challenge.example_css}\n</style>`)
|
||||
if (challenge.example_html) parts.push(challenge.example_html)
|
||||
if (challenge.example_js) parts.push(`<script>\n${challenge.example_js}\n<\/script>`)
|
||||
rawCode.value = parts.join("\n\n")
|
||||
})
|
||||
const challenge = reactive({
|
||||
display: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
score: 0,
|
||||
is_public: false,
|
||||
author_name: "",
|
||||
example_html: null as string | null,
|
||||
example_css: null as string | null,
|
||||
example_js: null as string | null,
|
||||
})
|
||||
|
||||
const canSubmit = computed(
|
||||
() => challenge.display && challenge.title && challenge.content,
|
||||
)
|
||||
async function getContent() {
|
||||
list.value = await Challenge.list()
|
||||
const display = Number(route.params.display)
|
||||
const target = list.value.find((item) => item.display === display)
|
||||
if (target) {
|
||||
show(display)
|
||||
} else if (list.value.length > 0) {
|
||||
show(list.value[0].display)
|
||||
}
|
||||
}
|
||||
|
||||
function createNew() {
|
||||
challenge.display = list.value[list.value.length - 1]?.display ?? 0 + 1
|
||||
challenge.title = ""
|
||||
challenge.content = ""
|
||||
challenge.score = 0
|
||||
challenge.is_public = false
|
||||
challenge.author_name = ""
|
||||
challenge.example_html = null
|
||||
challenge.example_css = null
|
||||
challenge.example_js = null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
await Challenge.createOrUpdate(challenge)
|
||||
message.success("提交成功")
|
||||
challenge.display = 0
|
||||
challenge.title = ""
|
||||
challenge.content = ""
|
||||
challenge.score = 0
|
||||
challenge.is_public = false
|
||||
challenge.author_name = ""
|
||||
challenge.example_html = null
|
||||
challenge.example_css = null
|
||||
challenge.example_js = null
|
||||
await getContent()
|
||||
} catch (error: any) {
|
||||
message.error(error.response.data.detail)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(display: number) {
|
||||
confirm.warning({
|
||||
title: "警告",
|
||||
content: "你确定要删除吗?",
|
||||
positiveText: "确定",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
await Challenge.remove(display)
|
||||
message.success("删除成功")
|
||||
getContent()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function show(display: number) {
|
||||
router.push({ name: "challenge-editor", params: { display } })
|
||||
const item = await Challenge.get(display)
|
||||
challenge.display = item.display
|
||||
challenge.title = item.title
|
||||
challenge.content = item.content
|
||||
challenge.score = item.score
|
||||
challenge.is_public = item.is_public
|
||||
challenge.author_name = item.author_name ?? ""
|
||||
challenge.example_html = item.example_html ?? null
|
||||
challenge.example_css = item.example_css ?? null
|
||||
challenge.example_js = item.example_js ?? null
|
||||
}
|
||||
|
||||
async function togglePublic(display: number) {
|
||||
const data = await Challenge.togglePublic(display)
|
||||
message.success(data.message)
|
||||
list.value = list.value.map((item) => {
|
||||
if (item.display === display) item.is_public = !item.is_public
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(getContent)
|
||||
</script>
|
||||
<style scoped>
|
||||
.list {
|
||||
overflow: auto;
|
||||
height: calc(100vh - 20px);
|
||||
}
|
||||
|
||||
.col {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor {
|
||||
height: calc(100vh - 200px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<div class="creation-layout">
|
||||
<div class="creation-sider">
|
||||
<n-tabs v-model:value="activeTab" type="line" class="left-tabs">
|
||||
<template #prefix>
|
||||
<n-button text @click="back" style="margin: 0 8px">
|
||||
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
|
||||
</n-button>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<n-flex style="margin: 0 8px">
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-button text :loading="savingCode" @click="saveCode(true)">
|
||||
<Icon :width="16" icon="lucide:save" />
|
||||
</n-button>
|
||||
</template>
|
||||
保存当前作品
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
</template>
|
||||
<n-tab-pane name="topic" tab="我的主题" display-directive="show">
|
||||
<div class="topic-pane">
|
||||
<n-form-item label="主题名称" :show-feedback="false">
|
||||
<n-input
|
||||
v-model:value="title"
|
||||
placeholder="给你的主题起个名字"
|
||||
maxlength="100"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
label="主题描述"
|
||||
:show-feedback="false"
|
||||
style="margin-top: 12px"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="content"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 8 }"
|
||||
placeholder="想做一个什么样的页面?有哪些颜色、布局、交互?"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-flex style="margin-top: 16px">
|
||||
<n-button
|
||||
:loading="savingTopic"
|
||||
:disabled="!title.trim()"
|
||||
@click="saveTopic"
|
||||
>
|
||||
保存主题
|
||||
</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!content.trim() || streaming"
|
||||
@click="generate"
|
||||
>
|
||||
用这个主题生成
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-text depth="3" class="topic-hint">
|
||||
自由创作只属于你自己,不会进入提交记录和评分。
|
||||
</n-text>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="chat" tab="AI 对话" display-directive="show">
|
||||
<PromptPanel :allow-submit="false" @deleted="historyRefreshKey++" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="history" tab="历史对话" display-directive="show">
|
||||
<PromptHistoryPanel
|
||||
:task-id="taskId"
|
||||
:active="activeTab === 'history'"
|
||||
:refresh-key="historyRefreshKey"
|
||||
@select="previewHistoryItem"
|
||||
@deleted="removeMessagePair"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
<div class="creation-content">
|
||||
<Preview
|
||||
:html="html"
|
||||
:css="css"
|
||||
:js="js"
|
||||
show-code-button
|
||||
clearable
|
||||
@showCode="showCode = true"
|
||||
@clear="clearAll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<n-modal
|
||||
v-model:show="showCode"
|
||||
preset="card"
|
||||
title="代码"
|
||||
style="width: 700px"
|
||||
>
|
||||
<n-tabs type="line">
|
||||
<n-tab-pane name="html" tab="HTML">
|
||||
<n-code :code="html" language="html" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="css" tab="CSS">
|
||||
<n-code :code="css" language="css" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="js" tab="JS">
|
||||
<n-code :code="js" language="javascript" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import PromptPanel from "../components/ai/PromptPanel.vue"
|
||||
import PromptHistoryPanel from "../components/ai/PromptHistoryPanel.vue"
|
||||
import Preview from "../components/editor/Preview.vue"
|
||||
import { Creation } from "../api"
|
||||
import { html, css, js } from "../store/editors"
|
||||
import {
|
||||
connectPrompt,
|
||||
disconnectPrompt,
|
||||
removeMessagePair,
|
||||
sendPrompt,
|
||||
setOnCodeComplete,
|
||||
streaming,
|
||||
} from "../store/prompt"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const display = Number(route.params.display)
|
||||
const activeTab = ref("topic")
|
||||
const taskId = ref(0)
|
||||
const title = ref("")
|
||||
const content = ref("")
|
||||
const showCode = ref(false)
|
||||
const savingTopic = ref(false)
|
||||
const savingCode = ref(false)
|
||||
const historyRefreshKey = ref(0)
|
||||
|
||||
watch(streaming, (val) => {
|
||||
if (val) activeTab.value = "chat"
|
||||
})
|
||||
|
||||
async function load() {
|
||||
const data = await Creation.get(display)
|
||||
taskId.value = data.task_id
|
||||
title.value = data.title
|
||||
content.value = data.content
|
||||
html.value = data.html
|
||||
css.value = data.css
|
||||
js.value = data.js
|
||||
connectPrompt(data.task_id)
|
||||
setOnCodeComplete(() => saveCode())
|
||||
}
|
||||
|
||||
async function saveTopic() {
|
||||
if (!title.value.trim()) return
|
||||
savingTopic.value = true
|
||||
try {
|
||||
await Creation.update(display, {
|
||||
title: title.value.trim(),
|
||||
content: content.value,
|
||||
})
|
||||
message.success("主题已保存")
|
||||
} catch {
|
||||
message.error("保存失败,请重试")
|
||||
} finally {
|
||||
savingTopic.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCode(notify = false) {
|
||||
savingCode.value = true
|
||||
try {
|
||||
await Creation.saveCode(display, {
|
||||
html: html.value,
|
||||
css: css.value,
|
||||
js: js.value,
|
||||
})
|
||||
if (notify) message.success("作品已保存")
|
||||
} catch {
|
||||
if (notify) message.error("保存失败,请重试")
|
||||
} finally {
|
||||
savingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function generate() {
|
||||
const text = content.value.trim()
|
||||
if (!text || streaming.value) return
|
||||
activeTab.value = "chat"
|
||||
sendPrompt(text)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
html.value = ""
|
||||
css.value = ""
|
||||
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()
|
||||
router.push({ name: "creation-list" })
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onUnmounted(disconnectPrompt)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.creation-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.creation-sider {
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--n-border-color, #efeff5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.creation-content {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-tabs {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.left-tabs :deep(.n-tabs-pane-wrapper) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.left-tabs :deep(.n-tab-pane) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.topic-pane {
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.topic-hint {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<main class="creation-home">
|
||||
<n-flex
|
||||
justify="space-between"
|
||||
align="flex-end"
|
||||
style="margin-bottom: 32px"
|
||||
>
|
||||
<div>
|
||||
<n-flex align="center" :size="8">
|
||||
<n-button text @click="$router.push({ name: 'home' })">
|
||||
<Icon :width="20" icon="pepicons-pencil:arrow-left" />
|
||||
</n-button>
|
||||
<n-h2 style="margin: 0">自由创作</n-h2>
|
||||
</n-flex>
|
||||
<n-text depth="3">自己定一个主题,和 AI 一起把它做出来</n-text>
|
||||
</div>
|
||||
<n-button type="primary" @click="openCreate">
|
||||
<template #icon>
|
||||
<Icon icon="lucide:plus" :width="16" />
|
||||
</template>
|
||||
新建主题
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<n-empty
|
||||
v-if="!loading && !creations.length"
|
||||
description="还没有创作,点右上角新建一个主题吧"
|
||||
style="margin-top: 72px"
|
||||
/>
|
||||
<div v-else class="card-grid">
|
||||
<n-card
|
||||
v-for="item in creations"
|
||||
:key="item.display"
|
||||
class="creation-card"
|
||||
hoverable
|
||||
@click="open(item)"
|
||||
>
|
||||
<template #header>
|
||||
<n-ellipsis style="max-width: 100%">{{ item.title }}</n-ellipsis>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-flex :size="4" align="center">
|
||||
<n-tag v-if="item.has_code" type="success" size="small">
|
||||
已有作品
|
||||
</n-tag>
|
||||
<n-button text @click.stop="remove(item)">
|
||||
<Icon icon="lucide:trash-2" :width="15" />
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<n-ellipsis :line-clamp="2" class="creation-desc">
|
||||
{{ item.content || "还没写主题描述" }}
|
||||
</n-ellipsis>
|
||||
<template #footer>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
更新于 {{ formatTime(item.modified) }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-card>
|
||||
</div>
|
||||
</n-spin>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showCreate"
|
||||
preset="card"
|
||||
title="新建主题"
|
||||
style="width: 520px"
|
||||
>
|
||||
<n-form>
|
||||
<n-form-item label="主题名称">
|
||||
<n-input
|
||||
v-model:value="form.title"
|
||||
placeholder="例如:我的番茄钟"
|
||||
maxlength="100"
|
||||
show-count
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="主题描述(可以之后再补充)">
|
||||
<n-input
|
||||
v-model:value="form.content"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 4, maxRows: 8 }"
|
||||
placeholder="想做一个什么样的页面?有哪些颜色、布局、交互?"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-flex justify="flex-end">
|
||||
<n-button @click="showCreate = false">取消</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="creating"
|
||||
:disabled="!form.title.trim()"
|
||||
@click="submit"
|
||||
>
|
||||
创建并开始
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
import { useDialog, useMessage } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { Creation } from "../api"
|
||||
import type { CreationSlim } from "../utils/type"
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const loading = ref(true)
|
||||
const creating = ref(false)
|
||||
const showCreate = ref(false)
|
||||
const creations = ref<CreationSlim[]>([])
|
||||
const form = reactive({ title: "", content: "" })
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Date(value).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
function open(item: CreationSlim) {
|
||||
router.push({ name: "creation", params: { display: item.display } })
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.title = ""
|
||||
form.content = ""
|
||||
showCreate.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const title = form.title.trim()
|
||||
if (!title) return
|
||||
creating.value = true
|
||||
try {
|
||||
const created = await Creation.create({ title, content: form.content })
|
||||
showCreate.value = false
|
||||
router.push({ name: "creation", params: { display: created.display } })
|
||||
} catch {
|
||||
message.error("创建失败,请重试")
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function remove(item: CreationSlim) {
|
||||
dialog.warning({
|
||||
title: "删除主题",
|
||||
content: `确定删除「${item.title}」吗?和它相关的对话也会一起删除。`,
|
||||
positiveText: "删除",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await Creation.remove(item.display)
|
||||
creations.value = creations.value.filter(
|
||||
(c) => c.display !== item.display,
|
||||
)
|
||||
message.success("已删除")
|
||||
} catch {
|
||||
message.error("删除失败,请重试")
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
creations.value = await Creation.listMine()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.creation-home {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 48px;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.creation-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.creation-desc {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<n-flex class="container" :wrap="false">
|
||||
<div class="sidebar">
|
||||
<div class="back-btn" @click="() => goHome($router, taskTab, step)">
|
||||
← 返回
|
||||
</div>
|
||||
<n-divider style="margin: 8px 0" />
|
||||
<div
|
||||
v-for="item in menu"
|
||||
:key="item.label"
|
||||
:class="['nav-item', { active: $route.name === item.route.name }]"
|
||||
@click="$router.push(item.route)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</n-flex>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
import { taskTab } from "../store/task"
|
||||
import { step } from "../store/tutorial"
|
||||
import { roleAdmin, roleSuper } from "../store/user"
|
||||
import { goHome } from "../utils/helper"
|
||||
|
||||
const menu = computed(() =>
|
||||
[
|
||||
{
|
||||
label: "教程",
|
||||
route: { name: "tutorial-editor", params: { display: step.value } },
|
||||
show: roleSuper.value,
|
||||
},
|
||||
{
|
||||
label: "挑战",
|
||||
route: { name: "challenge-editor", params: { display: 0 } },
|
||||
show: roleAdmin.value || roleSuper.value,
|
||||
},
|
||||
{
|
||||
label: "用户",
|
||||
route: { name: "user-manage", params: { page: 1 } },
|
||||
show: roleSuper.value,
|
||||
},
|
||||
{
|
||||
label: "工坊",
|
||||
route: { name: "showcase-manage" },
|
||||
show: roleSuper.value,
|
||||
},
|
||||
{
|
||||
label: "成绩",
|
||||
route: { name: "gradebook" },
|
||||
show: roleSuper.value,
|
||||
},
|
||||
{
|
||||
label: "提交",
|
||||
route: { name: "submissions", params: { page: 1 } },
|
||||
show: roleAdmin.value || roleSuper.value,
|
||||
},
|
||||
].filter((item) => item.show),
|
||||
)
|
||||
</script>
|
||||
<style scoped>
|
||||
.container {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 110px;
|
||||
min-width: 110px;
|
||||
padding: 12px 8px;
|
||||
border-right: 1px solid #efeff5;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
color 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background-color: #f0f0f0;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
color: #444;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
color 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background-color: #e8f8f0;
|
||||
color: #18a058;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,430 @@
|
||||
<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(() => 1040 + (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: 80,
|
||||
fixed: "left",
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: "small", type: gradeTagType(row.grade) },
|
||||
{ default: () => row.grade },
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "考核等级",
|
||||
key: "assessment_grade",
|
||||
width: 80,
|
||||
fixed: "left",
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: "small", type: gradeTagType(row.assessment_grade) },
|
||||
{ default: () => row.assessment_grade },
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "考核分数",
|
||||
key: "assessment_score",
|
||||
width: 80,
|
||||
fixed: "left",
|
||||
render: (row) => row.assessment_score,
|
||||
},
|
||||
{
|
||||
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>
|
||||
@@ -0,0 +1,165 @@
|
||||
<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)"
|
||||
>
|
||||
<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(item: ShowcaseItem) {
|
||||
const { href } = router.resolve({
|
||||
name: "submission",
|
||||
params: { id: item.submission_id },
|
||||
})
|
||||
window.open(href, "_blank")
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,650 @@
|
||||
<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,
|
||||
NButtonGroup,
|
||||
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 refreshingItemIds = 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: "is_stale",
|
||||
width: 96,
|
||||
render: (row) =>
|
||||
row.is_stale
|
||||
? h(NTag, { size: "small", type: "warning" }, { default: () => "有新提交" })
|
||||
: null,
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "actions",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(NButtonGroup, { size: "small" }, {
|
||||
default: () => [
|
||||
row.is_stale
|
||||
? h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
secondary: true,
|
||||
type: "warning",
|
||||
title: "切换到最新提交",
|
||||
loading: refreshingItemIds.value.has(row.id),
|
||||
onClick: () => refreshAwardItem(row),
|
||||
},
|
||||
{ icon: () => h(Icon, { icon: "lucide:refresh-cw", width: 14 }) },
|
||||
)
|
||||
: null,
|
||||
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
|
||||
}
|
||||
|
||||
function setRefreshingItem(id: number, loading: boolean) {
|
||||
const next = new Set(refreshingItemIds.value)
|
||||
if (loading) next.add(id)
|
||||
else next.delete(id)
|
||||
refreshingItemIds.value = next
|
||||
}
|
||||
|
||||
async function refreshAwardItem(row: AwardItemManageOut) {
|
||||
setRefreshingItem(row.id, true)
|
||||
try {
|
||||
const updated = await Showcase.refreshAwardItem(row.id)
|
||||
const idx = awardItems.value.findIndex((item) => item.id === row.id)
|
||||
if (idx !== -1) awardItems.value[idx] = updated
|
||||
message.success("已切换到最新提交")
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "切换失败")
|
||||
} finally {
|
||||
setRefreshingItem(row.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, useTemplateRef } from "vue"
|
||||
import { Submission } from "../api"
|
||||
import type { SubmissionAll } from "../utils/type"
|
||||
import { buildPreviewDocument } from "../utils/previewDocument"
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const iframe = useTemplateRef<HTMLIFrameElement>("iframe")
|
||||
|
||||
async function init() {
|
||||
const submission: SubmissionAll = await Submission.get(props.id)
|
||||
Submission.incrementView(props.id)
|
||||
|
||||
if (!iframe.value) return
|
||||
const doc = iframe.value.contentDocument
|
||||
if (doc) {
|
||||
doc.open()
|
||||
doc.write(
|
||||
buildPreviewDocument({
|
||||
html: submission.html,
|
||||
css: submission.css,
|
||||
js: submission.js,
|
||||
}),
|
||||
)
|
||||
doc.close()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(init)
|
||||
</script>
|
||||
<template>
|
||||
<iframe class="iframe" ref="iframe"></iframe>
|
||||
</template>
|
||||
<style scoped>
|
||||
.iframe {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<n-split
|
||||
class="container"
|
||||
direction="horizontal"
|
||||
:default-size="0.5"
|
||||
:min="0.2"
|
||||
:max="0.8"
|
||||
>
|
||||
<template #1>
|
||||
<n-flex
|
||||
vertical
|
||||
style="height: 100%; padding-right: 10px; overflow: hidden"
|
||||
>
|
||||
<n-flex align="center" justify="end">
|
||||
<n-select
|
||||
:value="query.flag"
|
||||
style="width: 100px"
|
||||
clearable
|
||||
placeholder="标记"
|
||||
:options="flagFilterOptions"
|
||||
@update:value="handleFlagSelect"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.zone"
|
||||
style="width: 100px"
|
||||
clearable
|
||||
placeholder="从夯到拉"
|
||||
:options="[
|
||||
{ label: '夯爆了', value: 'featured' },
|
||||
{ label: 'NPC', value: 'pending' },
|
||||
{ label: '拉完了', value: 'low' },
|
||||
]"
|
||||
/>
|
||||
<n-input
|
||||
style="width: 120px"
|
||||
v-model:value="query.username"
|
||||
clearable
|
||||
/>
|
||||
<n-pagination
|
||||
v-model:page="query.page"
|
||||
:page-size="10"
|
||||
:item-count="count"
|
||||
simple
|
||||
/>
|
||||
<n-button
|
||||
secondary
|
||||
style="padding: 0 10px"
|
||||
title="刷新"
|
||||
@click="init"
|
||||
>
|
||||
<Icon :width="16" icon="lucide:refresh-cw" />
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
flex-height
|
||||
striped
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:row-key="(row: SubmissionOut) => row.id"
|
||||
:expanded-row-keys="expandedKeys"
|
||||
@update:expanded-row-keys="handleExpand"
|
||||
:row-props="rowProps"
|
||||
:row-class-name="rowClassName"
|
||||
style="flex: 1; min-height: 0"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
<template #2>
|
||||
<div style="height: 100%; padding-left: 10px">
|
||||
<Preview
|
||||
v-if="submission.id"
|
||||
:html="html"
|
||||
:css="css"
|
||||
:js="js"
|
||||
:submission-id="submission.id"
|
||||
@after-score="afterScore"
|
||||
@show-code="codeModal = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</n-split>
|
||||
|
||||
<CodeModal
|
||||
v-model:show="codeModal"
|
||||
:html="html"
|
||||
:css="css"
|
||||
:js="js"
|
||||
@copy-to-editor="copyToEditor"
|
||||
/>
|
||||
|
||||
<ChainModal
|
||||
v-model:show="chainModal"
|
||||
:submission-id="chainSubmissionId"
|
||||
:username="chainUsername"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
||||
import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { Submission } from "../api"
|
||||
import type { SubmissionOut, FlagType } from "../utils/type"
|
||||
import { parseTime } from "../utils/helper"
|
||||
import { goHome } from "../utils/helper"
|
||||
import { TASK_TYPE } from "../utils/const"
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
|
||||
import Preview from "../components/editor/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, challengeDisplay } from "../store/task"
|
||||
import { step } from "../store/tutorial"
|
||||
import { html as eHtml, css as eCss, js as eJs } from "../store/editors"
|
||||
import { roleAdmin, roleSuper, user } from "../store/user"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
// 列表数据
|
||||
const data = ref<SubmissionOut[]>([])
|
||||
const count = ref(0)
|
||||
const query = reactive({
|
||||
page: Number(route.params.page),
|
||||
username: (Array.isArray(route.query.username)
|
||||
? ""
|
||||
: (route.query.username ?? "")) as string,
|
||||
flag: null as string | null,
|
||||
zone: null as string | null,
|
||||
})
|
||||
|
||||
// 当前选中提交的代码
|
||||
const html = computed(() => submission.value.html)
|
||||
const css = computed(() => submission.value.css)
|
||||
const js = computed(() => submission.value.js)
|
||||
|
||||
// Modal 状态
|
||||
const codeModal = ref(false)
|
||||
const chainModal = ref(false)
|
||||
const chainSubmissionId = ref<string>("")
|
||||
const chainUsername = ref<string>("")
|
||||
|
||||
// 展开行
|
||||
const expandedKeys = ref<string[]>([])
|
||||
const expandedData = reactive(new Map<string, SubmissionOut[]>())
|
||||
const expandedLoading = reactive(new Set<string>())
|
||||
|
||||
// 管理员判断
|
||||
const isAdmin = computed(() => roleAdmin.value || roleSuper.value)
|
||||
|
||||
// Flag 过滤选项
|
||||
const flagFilterOptions = computed(() => {
|
||||
const opts = [
|
||||
{ label: "红旗", value: "red" },
|
||||
{ label: "蓝旗", value: "blue" },
|
||||
{ label: "绿旗", value: "green" },
|
||||
{ label: "黄旗", value: "yellow" },
|
||||
{ label: "全部", value: "any" },
|
||||
]
|
||||
if (isAdmin.value) opts.push({ label: "清除", value: "_clear_all" })
|
||||
return opts
|
||||
})
|
||||
|
||||
function handleFlagSelect(value: string | null) {
|
||||
if (value === "_clear_all") {
|
||||
clearAllFlags()
|
||||
return
|
||||
}
|
||||
query.flag = value
|
||||
}
|
||||
|
||||
async function updateFlag(row: SubmissionOut, flag: FlagType) {
|
||||
await Submission.updateFlag(row.id, flag)
|
||||
row.flag = flag
|
||||
}
|
||||
|
||||
async function clearAllFlags() {
|
||||
await Submission.clearAllFlags()
|
||||
data.value = data.value.map((d) => ({ ...d, flag: null }))
|
||||
query.flag = null
|
||||
}
|
||||
|
||||
function showChain(submissionId: string, username: string) {
|
||||
chainSubmissionId.value = submissionId
|
||||
chainUsername.value = username
|
||||
chainModal.value = true
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
const columns: DataTableColumn<SubmissionOut>[] = [
|
||||
{
|
||||
type: "expand",
|
||||
expandable: () => true,
|
||||
renderExpand: (row) =>
|
||||
h(ExpandedSubTable, {
|
||||
row,
|
||||
items: expandedData.get(row.id),
|
||||
loading: expandedLoading.has(row.id),
|
||||
onSelect: (id) => getSubmissionByID(id),
|
||||
onDelete: (r, parentId) => handleDelete(r, parentId),
|
||||
"onShow-chain": (submissionId, username) =>
|
||||
showChain(submissionId, username),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "flag",
|
||||
width: 50,
|
||||
render: (row) =>
|
||||
h(FlagCell, {
|
||||
flag: row.flag ?? null,
|
||||
isAdmin: isAdmin.value,
|
||||
"onUpdate:flag": (flag: FlagType) => updateFlag(row, flag),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "zone",
|
||||
width: 42,
|
||||
render: (row) => {
|
||||
const map: Record<
|
||||
string,
|
||||
{ label: string; type: "success" | "default" | "warning" }
|
||||
> = {
|
||||
featured: { label: "夯", type: "success" },
|
||||
pending: { label: "N", type: "default" },
|
||||
low: { label: "拉", type: "warning" },
|
||||
}
|
||||
if (!row.zone || !map[row.zone]) return null
|
||||
const { label, type } = map[row.zone]
|
||||
return h(NTag, { size: "small", round: true, type }, () => label)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "时间",
|
||||
key: "created",
|
||||
width: 110,
|
||||
render: (row) => parseTime(row.created, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "提交者",
|
||||
key: "user",
|
||||
width: 80,
|
||||
render: (row) => row.username,
|
||||
},
|
||||
{
|
||||
title: "任务",
|
||||
key: "task_title",
|
||||
render: (row) => h(TaskTitle, { submission: row }),
|
||||
},
|
||||
{
|
||||
title: "得分",
|
||||
key: "score",
|
||||
width: 70,
|
||||
render: (row) => {
|
||||
const myScore = row.my_score > 0 ? String(row.my_score) : "-"
|
||||
const avgScore = row.score > 0 ? row.score.toFixed(2) : "-"
|
||||
return h(
|
||||
"div",
|
||||
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
|
||||
[
|
||||
h("span", avgScore),
|
||||
h("span", { style: { fontSize: "11px", color: "#999" } }, myScore),
|
||||
],
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "次数",
|
||||
key: "submit_count",
|
||||
width: 60,
|
||||
render: (row) => row.submit_count || "-",
|
||||
},
|
||||
{
|
||||
title: "浏览",
|
||||
key: "view_count",
|
||||
width: 60,
|
||||
render: (row) => row.view_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) {
|
||||
try {
|
||||
await Submission.delete(row.id)
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.detail ?? "删除失败,请重试")
|
||||
return
|
||||
}
|
||||
const items = expandedData.get(parentId)
|
||||
if (items)
|
||||
expandedData.set(
|
||||
parentId,
|
||||
items.filter((d) => d.id !== row.id),
|
||||
)
|
||||
if (submission.value.id === row.id) submission.value.id = ""
|
||||
const res = await Submission.list(query)
|
||||
data.value = res.items
|
||||
count.value = res.count
|
||||
}
|
||||
|
||||
function rowProps(row: SubmissionOut) {
|
||||
return {
|
||||
style: { cursor: "pointer" },
|
||||
onClick: () => {
|
||||
getSubmissionByID(row.id)
|
||||
handleExpand(
|
||||
expandedKeys.value.includes(row.id)
|
||||
? expandedKeys.value.filter((k) => k !== row.id)
|
||||
: [...expandedKeys.value, row.id],
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function rowClassName(row: SubmissionOut) {
|
||||
return submission.value.id === row.id ? "row-active" : ""
|
||||
}
|
||||
|
||||
async function init() {
|
||||
expandedKeys.value = []
|
||||
expandedData.clear()
|
||||
const res = await Submission.list(query)
|
||||
data.value = res.items
|
||||
count.value = res.count
|
||||
}
|
||||
|
||||
async function getSubmissionByID(id: string) {
|
||||
submission.value = await Submission.get(id)
|
||||
}
|
||||
|
||||
function afterScore() {
|
||||
data.value = data.value.map((d) => {
|
||||
if (d.id === submission.value.id) d.my_score = submission.value.my_score
|
||||
return d
|
||||
})
|
||||
}
|
||||
|
||||
function copyToEditor() {
|
||||
eHtml.value = html.value
|
||||
eCss.value = css.value
|
||||
eJs.value = js.value
|
||||
goHome(router, submission.value.task_type, submission.value.task_display)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => query.page,
|
||||
(v) => {
|
||||
init()
|
||||
router.push({ params: { page: v } })
|
||||
},
|
||||
)
|
||||
watchDebounced(
|
||||
() => query.username,
|
||||
() => {
|
||||
query.page = 1
|
||||
init()
|
||||
},
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
watch(
|
||||
() => query.flag,
|
||||
() => {
|
||||
query.page = 1
|
||||
init()
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => query.zone,
|
||||
() => {
|
||||
query.page = 1
|
||||
init()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(init)
|
||||
onUnmounted(() => {
|
||||
submission.value = {
|
||||
id: "",
|
||||
userid: 0,
|
||||
username: "",
|
||||
task_id: 0,
|
||||
task_display: 0,
|
||||
task_title: "",
|
||||
task_type: TASK_TYPE.Tutorial,
|
||||
score: 0,
|
||||
my_score: 0,
|
||||
html: "",
|
||||
css: "",
|
||||
js: "",
|
||||
submit_count: 0,
|
||||
view_count: 0,
|
||||
created: new Date(),
|
||||
modified: new Date(),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.row-active td) {
|
||||
background-color: rgba(24, 160, 80, 0.1) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<n-grid x-gap="10" :cols="8">
|
||||
<n-gi :span="2" class="col">
|
||||
<n-flex vertical class="list">
|
||||
<n-card
|
||||
v-for="item in list"
|
||||
:key="item.display"
|
||||
@click="show(item.display)"
|
||||
class="card"
|
||||
:header-style="{
|
||||
backgroundColor:
|
||||
item.display === tutorial.display ? 'rgba(24, 160, 80, 0.1)' : '',
|
||||
}"
|
||||
:embedded="!item.is_public"
|
||||
>
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<n-button text @click.stop="togglePublic(item.display)">
|
||||
<Icon
|
||||
width="24"
|
||||
:icon="
|
||||
item.is_public
|
||||
? 'twemoji:check-mark-button'
|
||||
: 'twemoji:locked'
|
||||
"
|
||||
></Icon>
|
||||
</n-button>
|
||||
<span>【{{ item.display }}】{{ item.title }}</span>
|
||||
</n-flex>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-button text type="default" @click="remove(item.display)">
|
||||
<Icon width="20" icon="material-symbols:close-rounded"></Icon>
|
||||
</n-button>
|
||||
</template>
|
||||
</n-card>
|
||||
<n-button @click="createNew">新建</n-button>
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
|
||||
<n-gi :span="6" class="col">
|
||||
<n-flex vertical>
|
||||
<n-form inline :show-feedback="false">
|
||||
<n-form-item label="序号" label-placement="left">
|
||||
<n-input-number
|
||||
style="width: 100px"
|
||||
v-model:value="tutorial.display"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="标题" label-placement="left">
|
||||
<n-input v-model:value="tutorial.title" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="公开" label-placement="left">
|
||||
<n-switch v-model:value="tutorial.is_public" />
|
||||
</n-form-item>
|
||||
<n-form-item label-placement="left">
|
||||
<n-button type="primary" @click="submit" :disabled="!canSubmit">
|
||||
提交
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-flex>
|
||||
<TaskAssetManager
|
||||
v-if="tutorial.display"
|
||||
task-type="tutorial"
|
||||
:display="tutorial.display"
|
||||
/>
|
||||
<n-button
|
||||
v-if="tutorial.display"
|
||||
size="small"
|
||||
@click="showExampleModal = true"
|
||||
>
|
||||
示例代码
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<MarkdownEditor
|
||||
style="height: calc(100vh - 100px)"
|
||||
v-model="tutorial.content"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showExampleModal"
|
||||
preset="card"
|
||||
title="示例代码(加载教程时自动填入编辑器)"
|
||||
style="width: 640px"
|
||||
>
|
||||
<n-input
|
||||
type="textarea"
|
||||
v-model:value="rawCode"
|
||||
placeholder="粘贴完整的前端代码,自动拆分为 HTML / CSS / JS..."
|
||||
:autosize="{ minRows: 10, maxRows: 30 }"
|
||||
/>
|
||||
<n-flex v-if="splitResult" style="margin-top: 8px">
|
||||
<n-tag size="small" type="success">HTML · {{ splitResult.html.length }} 字符</n-tag>
|
||||
<n-tag size="small" type="info">CSS · {{ splitResult.css.length }} 字符</n-tag>
|
||||
<n-tag size="small" type="warning">JS · {{ splitResult.js.length }} 字符</n-tag>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { Tutorial } from "../api"
|
||||
import type { TutorialSlim } from "../utils/type"
|
||||
import { useDialog, useMessage } from "naive-ui"
|
||||
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
|
||||
import TaskAssetManager from "../components/task/TaskAssetManager.vue"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const confirm = useDialog()
|
||||
|
||||
const list = ref<TutorialSlim[]>([])
|
||||
const showExampleModal = ref(false)
|
||||
const rawCode = ref("")
|
||||
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
|
||||
|
||||
function splitHtml(raw: string) {
|
||||
let result = raw
|
||||
const cssBlocks: string[] = []
|
||||
const jsBlocks: string[] = []
|
||||
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, c) => {
|
||||
cssBlocks.push(c.trim())
|
||||
return ""
|
||||
})
|
||||
result = result.replace(
|
||||
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
|
||||
(_, c) => {
|
||||
jsBlocks.push(c.trim())
|
||||
return ""
|
||||
},
|
||||
)
|
||||
return {
|
||||
html: result.trim(),
|
||||
css: cssBlocks.join("\n\n"),
|
||||
js: jsBlocks.join("\n\n"),
|
||||
}
|
||||
}
|
||||
|
||||
watch(rawCode, (val) => {
|
||||
if (!val.trim()) {
|
||||
splitResult.value = null
|
||||
tutorial.example_html = null
|
||||
tutorial.example_css = null
|
||||
tutorial.example_js = null
|
||||
return
|
||||
}
|
||||
const split = splitHtml(val)
|
||||
splitResult.value = split
|
||||
tutorial.example_html = split.html || null
|
||||
tutorial.example_css = split.css || null
|
||||
tutorial.example_js = split.js || null
|
||||
})
|
||||
|
||||
watch(showExampleModal, (visible) => {
|
||||
if (!visible) return
|
||||
const parts: string[] = []
|
||||
if (tutorial.example_css) parts.push(`<style>\n${tutorial.example_css}\n</style>`)
|
||||
if (tutorial.example_html) parts.push(tutorial.example_html)
|
||||
if (tutorial.example_js) parts.push(`<script>\n${tutorial.example_js}\n<\/script>`)
|
||||
rawCode.value = parts.join("\n\n")
|
||||
})
|
||||
const tutorial = reactive({
|
||||
display: 0,
|
||||
title: "",
|
||||
content: "",
|
||||
is_public: false,
|
||||
example_html: null as string | null,
|
||||
example_css: null as string | null,
|
||||
example_js: null as string | null,
|
||||
})
|
||||
|
||||
const canSubmit = computed(
|
||||
() => tutorial.display && tutorial.title && tutorial.content,
|
||||
)
|
||||
async function getContent() {
|
||||
list.value = await Tutorial.list()
|
||||
const display = Number(route.params.display)
|
||||
const target = list.value.find((item) => item.display === display)
|
||||
if (target) {
|
||||
show(display)
|
||||
} else if (list.value.length > 0) {
|
||||
show(list.value[0]!.display)
|
||||
}
|
||||
}
|
||||
|
||||
function createNew() {
|
||||
tutorial.display = list.value[list.value.length - 1]?.display ?? 0 + 1
|
||||
tutorial.title = ""
|
||||
tutorial.content = ""
|
||||
tutorial.is_public = false
|
||||
tutorial.example_html = null
|
||||
tutorial.example_css = null
|
||||
tutorial.example_js = null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
await Tutorial.createOrUpdate(tutorial)
|
||||
message.success("提交成功")
|
||||
tutorial.display = 0
|
||||
tutorial.title = ""
|
||||
tutorial.content = ""
|
||||
tutorial.is_public = false
|
||||
tutorial.example_html = null
|
||||
tutorial.example_css = null
|
||||
tutorial.example_js = null
|
||||
await getContent()
|
||||
} catch (error: any) {
|
||||
message.error(error.response.data.detail)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(display: number) {
|
||||
confirm.warning({
|
||||
title: "警告",
|
||||
content: "你确定要删除吗?",
|
||||
positiveText: "确定",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
await Tutorial.remove(display)
|
||||
message.success("删除成功")
|
||||
getContent()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function show(display: number) {
|
||||
router.push({ name: "tutorial-editor", params: { display } })
|
||||
const item = await Tutorial.get(display)
|
||||
tutorial.display = item.display
|
||||
tutorial.title = item.title
|
||||
tutorial.content = item.content
|
||||
tutorial.is_public = item.is_public
|
||||
tutorial.example_html = item.example_html ?? null
|
||||
tutorial.example_css = item.example_css ?? null
|
||||
tutorial.example_js = item.example_js ?? null
|
||||
}
|
||||
|
||||
async function togglePublic(display: number) {
|
||||
const data = await Tutorial.togglePublic(display)
|
||||
message.success(data.message)
|
||||
list.value = list.value.map((item) => {
|
||||
if (item.display === display) item.is_public = !item.is_public
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(getContent)
|
||||
</script>
|
||||
<style scoped>
|
||||
.list {
|
||||
overflow: auto;
|
||||
height: calc(100vh - 20px);
|
||||
}
|
||||
|
||||
.col {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor {
|
||||
height: calc(100vh - 200px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<n-flex vertical class="container">
|
||||
<n-flex>
|
||||
<div>
|
||||
<n-input
|
||||
style="width: 200px"
|
||||
v-model:value="query.username"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<n-select
|
||||
style="width: 120px"
|
||||
v-model:value="query.role"
|
||||
:options="roles"
|
||||
/>
|
||||
</div>
|
||||
<n-button @click="goDjangoUserAdd">新建一个</n-button>
|
||||
<n-button @click="showBatch = true">批量新建</n-button>
|
||||
<n-pagination
|
||||
v-model:page="query.page"
|
||||
:page-size="10"
|
||||
:item-count="count"
|
||||
simple
|
||||
/>
|
||||
</n-flex>
|
||||
<n-data-table striped :columns="columns" :data="users"></n-data-table>
|
||||
<n-modal
|
||||
style="width: 300px"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
title="批量新建 (前缀 web)"
|
||||
v-model:show="showBatch"
|
||||
>
|
||||
<n-flex vertical>
|
||||
<n-input
|
||||
placeholder="班级"
|
||||
v-model:value="classname"
|
||||
:disabled="batchLoading"
|
||||
/>
|
||||
<n-input
|
||||
rows="20"
|
||||
type="textarea"
|
||||
v-model:value="namesStr"
|
||||
:disabled="batchLoading"
|
||||
/>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!(classname && !!names.length)"
|
||||
:loading="batchLoading"
|
||||
@click="batchCreateUsers"
|
||||
>
|
||||
提交
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</n-flex>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, reactive, ref, h, watch, computed } from "vue"
|
||||
import { Account } from "../api"
|
||||
import { parseTime } from "../utils/helper"
|
||||
import { useMessage, type DataTableColumn } from "naive-ui"
|
||||
import { getRole, Role, type User } from "../utils/type"
|
||||
import { ADMIN_URL } from "../utils/const"
|
||||
import UserActions from "../components/dashboard/UserActions.vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
|
||||
const users = ref([])
|
||||
const count = ref(0)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const query = reactive({
|
||||
username: "",
|
||||
page: Number(route.params.page),
|
||||
role: "",
|
||||
})
|
||||
const showBatch = ref(false)
|
||||
const classname = ref("")
|
||||
const namesStr = ref("")
|
||||
const names = computed(() => namesStr.value.split("\n").filter((it) => !!it))
|
||||
|
||||
const batchLoading = ref(false)
|
||||
|
||||
const roles = [
|
||||
{ label: "全部权限", value: "" },
|
||||
{ label: "普通用户", value: Role.Normal },
|
||||
{ label: "管理员", value: Role.Admin },
|
||||
{ label: "超级管理员", value: Role.Super },
|
||||
]
|
||||
|
||||
const columns: DataTableColumn<User>[] = [
|
||||
{
|
||||
title: "用户名",
|
||||
key: "username",
|
||||
},
|
||||
{
|
||||
title: "密码",
|
||||
key: "raw_password",
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "date_joined",
|
||||
render: (row) => parseTime(row.date_joined, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: "上次登录",
|
||||
key: "last_login",
|
||||
render: (row) =>
|
||||
row.last_login
|
||||
? parseTime(row.last_login, "YYYY-MM-DD HH:mm:ss")
|
||||
: "从未登录",
|
||||
},
|
||||
{
|
||||
title: "权限",
|
||||
key: "role",
|
||||
render: (row) => getRole(row.role),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
render: (row) => h(UserActions, { user: row, onReload: init }),
|
||||
},
|
||||
]
|
||||
|
||||
function goDjangoUserAdd() {
|
||||
window.open(`${ADMIN_URL}/account/user/add/`)
|
||||
}
|
||||
|
||||
async function batchCreateUsers() {
|
||||
if (!names.value.length) return
|
||||
try {
|
||||
batchLoading.value = true
|
||||
const data = await Account.batchCreate({
|
||||
classname: classname.value,
|
||||
names: names.value,
|
||||
})
|
||||
batchLoading.value = false
|
||||
message.success(data.message)
|
||||
showBatch.value = false
|
||||
query.page = 1
|
||||
query.role = ""
|
||||
init()
|
||||
} catch (err: any) {
|
||||
batchLoading.value = false
|
||||
message.error(err.detail ?? "批量创建失败")
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const data = await Account.list(query)
|
||||
users.value = data.items
|
||||
count.value = data.count
|
||||
}
|
||||
|
||||
watch(
|
||||
() => query.role,
|
||||
() => {
|
||||
query.page = 1
|
||||
init()
|
||||
},
|
||||
)
|
||||
watchDebounced(
|
||||
() => query.username,
|
||||
() => {
|
||||
query.page = 1
|
||||
init()
|
||||
},
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
watch(
|
||||
() => query.page,
|
||||
(v) => {
|
||||
init()
|
||||
router.push({ params: { page: v } })
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(init)
|
||||
</script>
|
||||
<style scoped>
|
||||
.container {
|
||||
padding: 10px 10px 10px 0;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<n-split :size="panelSize" @update-size="changeSize" min="400px" max="900px">
|
||||
<template #1>
|
||||
<TaskPanel @hide="hide" />
|
||||
</template>
|
||||
<template #2>
|
||||
<n-split direction="vertical" min="200px">
|
||||
<template #1>
|
||||
<Editors />
|
||||
</template>
|
||||
<template #2>
|
||||
<Preview
|
||||
:html="html"
|
||||
:css="css"
|
||||
:js="js"
|
||||
:asset-base-url="assetBaseUrl"
|
||||
/>
|
||||
</template>
|
||||
</n-split>
|
||||
</template>
|
||||
</n-split>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useMagicKeys, whenever } from "@vueuse/core"
|
||||
import Editors from "../components/editor/Editors.vue"
|
||||
import Preview from "../components/editor/Preview.vue"
|
||||
import TaskPanel from "../components/task/TaskPanel.vue"
|
||||
import { show, panelSize } from "../store/panel"
|
||||
import { html, css, js } from "../store/editors"
|
||||
import { assetBaseUrl } from "../store/task"
|
||||
|
||||
const { ctrl_s } = useMagicKeys({
|
||||
passive: false,
|
||||
onEventFired(e) {
|
||||
if (e.ctrlKey && e.key === "s" && e.type === "keydown") e.preventDefault()
|
||||
},
|
||||
})
|
||||
|
||||
const { ctrl_r } = useMagicKeys({
|
||||
passive: false,
|
||||
onEventFired(e) {
|
||||
if (e.ctrlKey && e.key === "r" && e.type === "keydown") e.preventDefault()
|
||||
},
|
||||
})
|
||||
|
||||
function changeSize(n: number) {
|
||||
panelSize.value = n
|
||||
if (n > 0) show.value = true
|
||||
}
|
||||
|
||||
function hide() {
|
||||
panelSize.value = 0
|
||||
show.value = false
|
||||
}
|
||||
|
||||
whenever(ctrl_s!, () => {})
|
||||
whenever(ctrl_r!, () => {})
|
||||
</script>
|
||||
Reference in new issue
Block a user