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

This commit is contained in:
2026-06-07 05:50:51 -06:00
parent 4b32330b60
commit 6f99688667
7 changed files with 247 additions and 19 deletions

View File

@@ -31,6 +31,7 @@
type="password"
v-model:value="studentPassword"
name="password"
@keyup.enter="submitStudent"
/>
</n-form-item>
<n-alert
@@ -73,6 +74,7 @@
type="password"
v-model:value="adminPassword"
name="password"
@keyup.enter="submitAdmin"
/>
</n-form-item>
<n-alert

View File

@@ -40,6 +40,9 @@ async function render() {
const data = await Tutorial.get(step.value)
taskId.value = data.task_ptr
assetBaseUrl.value = `/media/tasks/tutorial/${step.value}/`
html.value = data.example_html ?? ""
css.value = data.example_css ?? ""
js.value = data.example_js ?? ""
const merged = `# ${data.display}. ${data.title}\n${data.content}`
content.value = await marked.parse(merged, { async: true })
}

View File

@@ -36,9 +36,18 @@
<n-tab-pane name="desc" tab="挑战描述" display-directive="show">
<div class="desc-pane">
<div class="challenge-meta">
<n-text depth="3">
出题人{{ challengeAuthor || "未设置" }}
</n-text>
<n-flex align="center" justify="space-between">
<n-text depth="3">
出题人{{ challengeAuthor || "未设置" }}
</n-text>
<n-button
v-if="exampleCode"
size="small"
@click="previewExample"
>
看示例
</n-button>
</n-flex>
</div>
<div
class="markdown-body content no-select"
@@ -182,6 +191,7 @@ 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}/`,
@@ -201,6 +211,15 @@ async function loadChallenge() {
])
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,
@@ -234,6 +253,13 @@ function edit() {
})
}
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 = ""

View File

@@ -76,11 +76,20 @@
</n-button>
</n-form-item>
</n-form>
<TaskAssetManager
v-if="challenge.display"
task-type="challenge"
:display="challenge.display"
/>
<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"
@@ -88,9 +97,28 @@
</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 } from "vue"
import { computed, onMounted, reactive, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { Icon } from "@iconify/vue"
import { Challenge } from "../api"
@@ -105,6 +133,55 @@ 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: "",
@@ -112,6 +189,9 @@ const challenge = reactive({
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(
@@ -135,6 +215,9 @@ function createNew() {
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() {
@@ -147,6 +230,9 @@ async function submit() {
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)
@@ -176,6 +262,9 @@ async function show(display: number) {
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) {

View File

@@ -61,11 +61,20 @@
</n-button>
</n-form-item>
</n-form>
<TaskAssetManager
v-if="tutorial.display"
task-type="tutorial"
:display="tutorial.display"
/>
<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"
@@ -73,9 +82,28 @@
</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 } from "vue"
import { computed, onMounted, reactive, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { Icon } from "@iconify/vue"
import { Tutorial } from "../api"
@@ -90,11 +118,63 @@ 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(
@@ -116,6 +196,9 @@ function createNew() {
tutorial.title = ""
tutorial.content = ""
tutorial.is_public = false
tutorial.example_html = null
tutorial.example_css = null
tutorial.example_js = null
}
async function submit() {
@@ -126,6 +209,9 @@ async function submit() {
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)
@@ -153,6 +239,9 @@ async function show(display: number) {
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) {

View File

@@ -5,25 +5,28 @@ import Workspace from "./pages/Workspace.vue"
import { STORAGE_KEY } from "./utils/const"
const routes = [
{ path: "/", name: "home", component: Workspace },
{ path: "/tutorial", name: "home-tutorial-list", component: Workspace },
{ path: "/tutorial/:display", name: "home-tutorial", component: Workspace },
{ path: "/challenge", name: "home-challenge-list", component: Workspace },
{ path: "/", name: "home", component: Workspace, meta: { auth: true } },
{ path: "/tutorial", name: "home-tutorial-list", component: Workspace, meta: { auth: true } },
{ path: "/tutorial/:display", name: "home-tutorial", component: Workspace, meta: { auth: true } },
{ path: "/challenge", name: "home-challenge-list", component: Workspace, meta: { auth: true } },
{
path: "/challenge/:display",
name: "home-challenge",
component: () => import("./pages/ChallengeDetail.vue"),
meta: { auth: true },
},
{
path: "/submissions/:page",
name: "submissions",
component: () => import("./pages/Submissions.vue"),
meta: { auth: true },
},
{
path: "/submission/:id",
name: "submission",
component: () => import("./pages/Submission.vue"),
props: true,
meta: { auth: true },
},
{
path: "/showcase",

View File

@@ -54,12 +54,18 @@ export interface TutorialSlim {
export interface TutorialReturn extends TutorialSlim {
content: string
example_html: string | null
example_css: string | null
example_js: string | null
}
export interface TutorialIn {
display: number
title: string
content: string
example_html?: string | null
example_css?: string | null
example_js?: string | null
}
export interface ChallengeSlim {
@@ -72,12 +78,22 @@ export interface ChallengeSlim {
author_name: string | null
}
export interface ChallengeReturn extends ChallengeSlim {
content: string
example_html: string | null
example_css: string | null
example_js: string | null
}
export interface ChallengeIn {
display: number
title: string
content: string
score: number
is_public: boolean
example_html?: string | null
example_css?: string | null
example_js?: string | null
}
export interface User {