feat: 改版阶段 4:挑战列表页、教程按小节存代码、出题页合并、用户管理、整站暗色
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 挑战列表改成独立页面(ChallengeHome):每题一行,完成状态、分值、出题人,可按未做 / 已交筛选; 教程面板只放教程 - 教程每一节的代码各存一份(和挑战同一个按任务的 key),切回来时还在,第一次才放示例代码; 提交列表“复制到编辑器”对教程也写入该任务的代码 - 教程 / 挑战出题页合并为 TaskEditor:“提交”改为“保存”,保存后停在当前项,删除前说明会连带删除学生提交; admin 也能在列表里切换挑战是否公开(保存时本来就能设) - 用户管理:初始密码默认隐藏,封号前确认,批量新建说明用户名规则 - 整站暗色:顶栏切换跟随系统 / 亮色 / 暗色;Naive 暗色主题、markdown 和代码高亮的暗色覆盖 (styles/dark-content.css 由 GitHub 暗色样式生成)、CodeMirror One Dark 配色、md-editor 暗色; 组件里剩下的硬编码颜色全部换成配色变量 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
df3f842dac
commit
8653d411a6
40 files changed
+1208
-851
No files matched your search
@@ -1,313 +0,0 @@
|
||||
<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
|
||||
:disabled="!roleSuper"
|
||||
@click.stop="togglePublic(item.display)"
|
||||
>
|
||||
<Icon
|
||||
width="24"
|
||||
:icon="
|
||||
item.is_public
|
||||
? 'lucide:globe'
|
||||
: 'lucide:lock'
|
||||
"
|
||||
></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(var(--wp-main-h) - 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 "../icons"
|
||||
import { Challenge } from "../api"
|
||||
import { roleSuper } from "../store/user"
|
||||
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(var(--wp-main-h) - 20px);
|
||||
}
|
||||
|
||||
.col {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor {
|
||||
height: calc(var(--wp-main-h) - 200px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<main class="challenge-home">
|
||||
<header class="head">
|
||||
<div>
|
||||
<h2>挑战</h2>
|
||||
<p class="sub">
|
||||
用提示词让 AI 做出题目要求的网页。已完成 {{ doneCount }} / {{ challenges.length }}
|
||||
</p>
|
||||
</div>
|
||||
<n-radio-group v-model:value="filter" size="small">
|
||||
<n-radio-button value="all">全部</n-radio-button>
|
||||
<n-radio-button value="todo">未做</n-radio-button>
|
||||
<n-radio-button value="done">已交</n-radio-button>
|
||||
</n-radio-group>
|
||||
</header>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<n-empty
|
||||
v-if="!loading && !visible.length"
|
||||
:description="challenges.length ? '没有符合条件的挑战' : '暂无挑战,敬请期待'"
|
||||
style="margin-top: 72px"
|
||||
/>
|
||||
<ol v-else class="list">
|
||||
<li v-for="item in visible" :key="item.display">
|
||||
<router-link
|
||||
class="row"
|
||||
:to="{ name: 'home-challenge', params: { display: item.display } }"
|
||||
>
|
||||
<span class="num">{{ item.display }}</span>
|
||||
<span class="main">
|
||||
<span class="title">{{ item.title }}</span>
|
||||
<span class="author">出题人:{{ item.author_name || "未设置" }}</span>
|
||||
</span>
|
||||
<span class="score">{{ item.score }} 分</span>
|
||||
<span :class="['state', { done: item.submitted }]">
|
||||
<template v-if="item.submitted">
|
||||
<Icon icon="lucide:check" :width="14" /> 已交
|
||||
</template>
|
||||
<template v-else>未做</template>
|
||||
</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ol>
|
||||
</n-spin>
|
||||
</main>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue"
|
||||
import { Icon } from "../icons"
|
||||
import { Challenge } from "../api"
|
||||
import { taskId, taskTab } from "../store/task"
|
||||
import { TASK_TYPE } from "../utils/const"
|
||||
import type { ChallengeSlim } from "../utils/type"
|
||||
|
||||
const challenges = ref<ChallengeSlim[]>([])
|
||||
const loading = ref(true)
|
||||
const filter = ref<"all" | "todo" | "done">("all")
|
||||
|
||||
const doneCount = computed(() => challenges.value.filter((c) => c.submitted).length)
|
||||
const visible = computed(() =>
|
||||
challenges.value.filter((c) =>
|
||||
filter.value === "todo" ? !c.submitted : filter.value === "done" ? c.submitted : true,
|
||||
),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
taskTab.value = TASK_TYPE.Challenge
|
||||
taskId.value = 0
|
||||
try {
|
||||
challenges.value = await Challenge.listDisplay()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.challenge-home {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 20px 48px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 4px 0 0;
|
||||
color: var(--wp-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 10px;
|
||||
background: var(--wp-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list li + li {
|
||||
border-top: 1px solid var(--wp-line);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr auto 72px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
color: var(--wp-ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--wp-paper);
|
||||
}
|
||||
|
||||
.row:focus-visible {
|
||||
outline: 2px solid var(--wp-brand);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.num {
|
||||
font: 600 15px var(--wp-font-mono);
|
||||
color: var(--wp-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: 12px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.score {
|
||||
font: 13px var(--wp-font-mono);
|
||||
color: var(--wp-warn);
|
||||
}
|
||||
|
||||
.state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
font-size: 13px;
|
||||
color: var(--wp-faint);
|
||||
}
|
||||
|
||||
.state.done {
|
||||
color: var(--wp-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -197,6 +197,6 @@ onMounted(async () => {
|
||||
|
||||
.creation-desc {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -397,7 +397,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.task-header.muted {
|
||||
color: #888;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.task-title {
|
||||
@@ -414,21 +414,21 @@ onMounted(async () => {
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
color: #999;
|
||||
color: var(--wp-faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.missing-cell {
|
||||
color: #d03050;
|
||||
color: var(--wp-bad);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.score-link.muted {
|
||||
color: #777;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
:deep(.excluded-task-column) {
|
||||
background: #fafafa;
|
||||
background: var(--wp-paper);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -443,7 +443,7 @@ onUnmounted(() => window.removeEventListener("keydown", onKey))
|
||||
height: 100px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
background: var(--wp-surface);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--wp-faint);
|
||||
|
||||
@@ -53,20 +53,20 @@
|
||||
<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;">
|
||||
<n-text style="font-size: 12px; color: var(--wp-muted);">
|
||||
{{ 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;">
|
||||
<n-text style="font-size: 12px; color: var(--wp-muted);">
|
||||
{{ 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;"
|
||||
style="display: block; margin-top: 4px; font-size: 12px; line-height: 1.4; color: var(--wp-muted);"
|
||||
>
|
||||
{{ item.task_title }}
|
||||
</n-ellipsis>
|
||||
@@ -142,7 +142,7 @@ onMounted(init)
|
||||
position: relative;
|
||||
height: 160px;
|
||||
overflow: hidden;
|
||||
background: #f7f8fa;
|
||||
background: var(--wp-paper);
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
@@ -162,6 +162,6 @@ onMounted(init)
|
||||
.card-info {
|
||||
min-height: 72px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-top: 1px solid var(--wp-line);
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
:width="260"
|
||||
bordered
|
||||
content-style="overflow: auto; height: 100%;"
|
||||
style="background: #fafafa;"
|
||||
style="background: var(--wp-paper);"
|
||||
>
|
||||
<n-flex class="panel-header" justify="space-between" align="center">
|
||||
<n-text strong>奖项</n-text>
|
||||
@@ -35,7 +35,7 @@
|
||||
<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-flex align="center" :size="6" style="flex-shrink: 0; color: var(--wp-muted); font-size: 12px;">
|
||||
<n-tag v-if="!award.is_active" size="small">停用</n-tag>
|
||||
<span>{{ award.item_count }} 件</span>
|
||||
</n-flex>
|
||||
@@ -615,8 +615,8 @@ onMounted(async () => {
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #efeff5;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid var(--wp-line);
|
||||
background: var(--wp-paper);
|
||||
}
|
||||
|
||||
.award-row {
|
||||
@@ -628,15 +628,15 @@ onMounted(async () => {
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid var(--wp-line);
|
||||
background: transparent;
|
||||
color: #333;
|
||||
color: var(--wp-ink);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.award-row:hover {
|
||||
background: #f2f5f8;
|
||||
background: var(--wp-paper);
|
||||
}
|
||||
|
||||
.award-row.active {
|
||||
|
||||
@@ -105,7 +105,6 @@ import ChainModal from "../components/submissions/ChainModal.vue"
|
||||
import ExpandedSubTable from "../components/submissions/ExpandedSubTable.vue"
|
||||
|
||||
import { submission } from "../store/submission"
|
||||
import { html as eHtml, css as eCss, js as eJs } from "../store/editors"
|
||||
import { roleAdmin, roleSuper } from "../store/user"
|
||||
|
||||
const route = useRoute()
|
||||
@@ -218,7 +217,7 @@ const columns: DataTableColumn<SubmissionOut>[] = [
|
||||
{ style: { display: "flex", gap: "6px", alignItems: "baseline" } },
|
||||
[
|
||||
h("span", avgScore),
|
||||
h("span", { style: { fontSize: "11px", color: "#999" } }, myScore),
|
||||
h("span", { style: { fontSize: "11px", color: "var(--wp-faint)" } }, myScore),
|
||||
],
|
||||
)
|
||||
},
|
||||
@@ -321,15 +320,8 @@ function afterScore() {
|
||||
}
|
||||
|
||||
function copyToEditor() {
|
||||
const code = { html: html.value, css: css.value, js: js.value }
|
||||
// 挑战页按任务各存一份代码,教程页还是共用编辑器
|
||||
if (submission.value.task_type === TASK_TYPE.Challenge) {
|
||||
saveTaskCode(submission.value.task_id, code)
|
||||
} else {
|
||||
eHtml.value = code.html
|
||||
eCss.value = code.css
|
||||
eJs.value = code.js
|
||||
}
|
||||
// 教程和挑战的代码都按任务各存一份,打开那个任务时读到
|
||||
saveTaskCode(submission.value.task_id, { html: html.value, css: css.value, js: js.value })
|
||||
goHome(router, submission.value.task_type, submission.value.task_display)
|
||||
}
|
||||
|
||||
@@ -395,6 +387,6 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
:deep(.row-active td) {
|
||||
background-color: rgba(24, 160, 80, 0.1) !important;
|
||||
background-color: var(--wp-brand-soft) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,342 @@
|
||||
<template>
|
||||
<div class="editor-page">
|
||||
<aside class="list">
|
||||
<div class="list-head">
|
||||
<b>{{ kindLabel }}</b>
|
||||
<n-button size="small" @click="createNew">
|
||||
<template #icon><Icon icon="lucide:plus" /></template>
|
||||
新建
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="items">
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.display"
|
||||
:class="['item', { on: item.display === form.display, draft: !item.is_public }]"
|
||||
@click="show(item.display)"
|
||||
>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button text @click.stop="togglePublic(item.display)">
|
||||
<Icon :icon="item.is_public ? 'lucide:globe' : 'lucide:lock'" :width="16" />
|
||||
</n-button>
|
||||
</template>
|
||||
{{ item.is_public ? "已公开,点击设为不公开" : "未公开,点击公开" }}
|
||||
</n-tooltip>
|
||||
<span class="item-title">{{ item.display }}. {{ item.title }}</span>
|
||||
<span v-if="kind === 'challenge'" class="item-score">{{ item.score }}分</span>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button text class="item-del" @click.stop="remove(item.display)">
|
||||
<Icon icon="lucide:trash-2" :width="14" />
|
||||
</n-button>
|
||||
</template>
|
||||
删除
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="form">
|
||||
<n-form inline :show-feedback="false" class="fields">
|
||||
<n-form-item label="序号" label-placement="left">
|
||||
<n-input-number style="width: 96px" v-model:value="form.display" />
|
||||
</n-form-item>
|
||||
<n-form-item label="标题" label-placement="left">
|
||||
<n-input v-model:value="form.title" style="width: 220px" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="kind === 'challenge'" label="分数" label-placement="left">
|
||||
<n-input-number style="width: 96px" v-model:value="form.score" :min="0" />
|
||||
</n-form-item>
|
||||
<n-form-item label="公开" label-placement="left">
|
||||
<n-switch v-model:value="form.is_public" />
|
||||
</n-form-item>
|
||||
<n-form-item v-if="kind === 'challenge'" label="出题人" label-placement="left">
|
||||
<n-text depth="3">{{ form.author_name || "未设置" }}</n-text>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-flex align="center" class="actions">
|
||||
<n-button type="primary" :disabled="!canSave" :loading="saving" @click="save">
|
||||
保存
|
||||
</n-button>
|
||||
<TaskAssetManager v-if="form.display" :task-type="kind" :display="form.display" />
|
||||
<n-button v-if="form.display" @click="showExampleModal = true">示例代码</n-button>
|
||||
<n-text depth="3" class="hint">{{ exampleHint }}</n-text>
|
||||
</n-flex>
|
||||
<MarkdownEditor class="md" v-model="form.content" />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showExampleModal"
|
||||
preset="card"
|
||||
:title="`示例代码(${exampleHint})`"
|
||||
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>
|
||||
<template #footer>
|
||||
<n-text depth="3">关掉窗口后点“保存”才会存下来。</n-text>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { useDialog, useMessage } from "naive-ui"
|
||||
import { Icon } from "../icons"
|
||||
import { Challenge, Tutorial } from "../api"
|
||||
import { splitHtml, type Code } from "../utils/code"
|
||||
import MarkdownEditor from "../components/dashboard/MarkdownEditor.vue"
|
||||
import TaskAssetManager from "../components/task/TaskAssetManager.vue"
|
||||
|
||||
// 教程和挑战的出题页(以前是两个几乎一样的文件)
|
||||
const props = defineProps<{ kind: "tutorial" | "challenge" }>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const api = props.kind === "tutorial" ? Tutorial : Challenge
|
||||
const kindLabel = props.kind === "tutorial" ? "教程" : "挑战"
|
||||
const routeName = props.kind === "tutorial" ? "tutorial-editor" : "challenge-editor"
|
||||
const exampleHint =
|
||||
props.kind === "tutorial" ? "打开这一节教程时自动放进编辑器" : "学生点“放入示例代码”时使用"
|
||||
|
||||
interface ListItem {
|
||||
display: number
|
||||
title: string
|
||||
is_public: boolean
|
||||
score?: number
|
||||
}
|
||||
|
||||
const list = ref<ListItem[]>([])
|
||||
const saving = ref(false)
|
||||
const showExampleModal = ref(false)
|
||||
const rawCode = ref("")
|
||||
const splitResult = ref<Code | null>(null)
|
||||
|
||||
const blank = () => ({
|
||||
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 form = reactive(blank())
|
||||
|
||||
const canSave = computed(() => !!(form.display && form.title && form.content))
|
||||
|
||||
watch(rawCode, (val) => {
|
||||
if (!val.trim()) {
|
||||
splitResult.value = null
|
||||
Object.assign(form, { example_html: null, example_css: null, example_js: null })
|
||||
return
|
||||
}
|
||||
const split = splitHtml(val)
|
||||
splitResult.value = split
|
||||
form.example_html = split.html || null
|
||||
form.example_css = split.css || null
|
||||
form.example_js = split.js || null
|
||||
})
|
||||
|
||||
watch(showExampleModal, (visible) => {
|
||||
if (!visible) return
|
||||
const parts: string[] = []
|
||||
if (form.example_css) parts.push(`<style>\n${form.example_css}\n</style>`)
|
||||
if (form.example_html) parts.push(form.example_html)
|
||||
if (form.example_js) parts.push(`<script>\n${form.example_js}\n<\/script>`)
|
||||
rawCode.value = parts.join("\n\n")
|
||||
})
|
||||
|
||||
async function loadList() {
|
||||
list.value = await api.list()
|
||||
}
|
||||
|
||||
async function show(display: number) {
|
||||
router.replace({ name: routeName, params: { display } })
|
||||
const item: any = await api.get(display)
|
||||
Object.assign(form, blank(), {
|
||||
display: item.display,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
score: item.score ?? 0,
|
||||
is_public: item.is_public,
|
||||
author_name: item.author_name ?? "",
|
||||
example_html: item.example_html ?? null,
|
||||
example_css: item.example_css ?? null,
|
||||
example_js: item.example_js ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
function createNew() {
|
||||
// 列表按序号升序,新的接在最后
|
||||
Object.assign(form, blank(), {
|
||||
display: (list.value[list.value.length - 1]?.display ?? 0) + 1,
|
||||
})
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await api.createOrUpdate(form as any)
|
||||
message.success(res?.message ?? "已保存")
|
||||
await loadList()
|
||||
// 保存后留在这一项上,方便接着改
|
||||
await show(form.display)
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.detail ?? "保存失败")
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function remove(display: number) {
|
||||
const item = list.value.find((i) => i.display === display)
|
||||
dialog.warning({
|
||||
title: `删除${kindLabel}「${item?.title ?? display}」?`,
|
||||
content: "学生在这个任务上的提交和对话也会一起删除,不能恢复。",
|
||||
positiveText: "删除",
|
||||
negativeText: "取消",
|
||||
onPositiveClick: async () => {
|
||||
await api.remove(display)
|
||||
message.success("已删除")
|
||||
await loadList()
|
||||
if (form.display === display) {
|
||||
if (list.value.length) show(list.value[0]!.display)
|
||||
else createNew()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function togglePublic(display: number) {
|
||||
const data = await api.togglePublic(display)
|
||||
message.success(data.message)
|
||||
const item = list.value.find((i) => i.display === display)
|
||||
if (item) item.is_public = !item.is_public
|
||||
if (form.display === display) form.is_public = !form.is_public
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadList()
|
||||
const display = Number(route.params.display)
|
||||
if (list.value.some((item) => item.display === display)) show(display)
|
||||
else if (list.value.length) show(list.value[0]!.display)
|
||||
else createNew()
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.editor-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
width: 280px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--wp-line);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--wp-line);
|
||||
}
|
||||
|
||||
.items {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--wp-paper);
|
||||
}
|
||||
|
||||
.item.on {
|
||||
background: var(--wp-brand-soft);
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.item.draft .item-title {
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.item-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-score {
|
||||
font: 12px var(--wp-font-mono);
|
||||
color: var(--wp-warn);
|
||||
}
|
||||
|
||||
.item-del {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.item:hover .item-del,
|
||||
.item-del:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.form {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.fields {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.md {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,275 +0,0 @@
|
||||
<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
|
||||
? 'lucide:globe'
|
||||
: 'lucide:lock'
|
||||
"
|
||||
></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(var(--wp-main-h) - 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 "../icons"
|
||||
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(var(--wp-main-h) - 20px);
|
||||
}
|
||||
|
||||
.col {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor {
|
||||
height: calc(var(--wp-main-h) - 200px);
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@
|
||||
<n-input
|
||||
style="width: 200px"
|
||||
v-model:value="query.username"
|
||||
placeholder="搜索用户名"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
@@ -27,21 +28,26 @@
|
||||
<n-data-table striped :columns="columns" :data="users"></n-data-table>
|
||||
<UserFormModal v-model:show="showCreate" @saved="init" />
|
||||
<n-modal
|
||||
style="width: 300px"
|
||||
style="width: 440px"
|
||||
:mask-closable="false"
|
||||
preset="card"
|
||||
title="批量新建 (前缀 web)"
|
||||
title="批量新建学生"
|
||||
v-model:show="showBatch"
|
||||
>
|
||||
<n-flex vertical>
|
||||
<n-text depth="3" style="font-size: 13px">
|
||||
每行一个名字。用户名是 web + 班级 + 名字(比如
|
||||
web{{ classname || "241" }}{{ names[0] || "张三" }}),密码随机生成,建好后在列表里点“显示”查看。
|
||||
</n-text>
|
||||
<n-input
|
||||
placeholder="班级"
|
||||
placeholder="班级,比如 241"
|
||||
v-model:value="classname"
|
||||
:disabled="batchLoading"
|
||||
/>
|
||||
<n-input
|
||||
rows="20"
|
||||
rows="16"
|
||||
type="textarea"
|
||||
placeholder="张三 李四 王五"
|
||||
v-model:value="namesStr"
|
||||
:disabled="batchLoading"
|
||||
/>
|
||||
@@ -51,7 +57,7 @@
|
||||
:loading="batchLoading"
|
||||
@click="batchCreateUsers"
|
||||
>
|
||||
提交
|
||||
创建 {{ names.length }} 个账号
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
@@ -61,7 +67,7 @@
|
||||
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 { NButton, useMessage, type DataTableColumn } from "naive-ui"
|
||||
import { getRole, Role, type User } from "../utils/type"
|
||||
import UserActions from "../components/dashboard/UserActions.vue"
|
||||
import UserFormModal from "../components/dashboard/UserFormModal.vue"
|
||||
@@ -82,7 +88,13 @@ const showBatch = ref(false)
|
||||
const showCreate = ref(false)
|
||||
const classname = ref("")
|
||||
const namesStr = ref("")
|
||||
const names = computed(() => namesStr.value.split("\n").filter((it) => !!it))
|
||||
const names = computed(() =>
|
||||
namesStr.value
|
||||
.split("\n")
|
||||
.map((it) => it.trim())
|
||||
.filter((it) => !!it),
|
||||
)
|
||||
const revealed = reactive(new Set<number>())
|
||||
|
||||
const batchLoading = ref(false)
|
||||
|
||||
@@ -99,8 +111,19 @@ const columns: DataTableColumn<User>[] = [
|
||||
key: "username",
|
||||
},
|
||||
{
|
||||
title: "密码",
|
||||
// 密码默认不显示,投屏时不会被学生看到
|
||||
title: "初始密码",
|
||||
key: "raw_password",
|
||||
render: (row) =>
|
||||
!row.raw_password
|
||||
? "—"
|
||||
: revealed.has(row.id)
|
||||
? h("span", { style: "font-family: var(--wp-font-mono)" }, row.raw_password)
|
||||
: h(
|
||||
NButton,
|
||||
{ text: true, size: "small", onClick: () => revealed.add(row.id) },
|
||||
() => "显示",
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
</n-split>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { watch } from "vue"
|
||||
import { useMagicKeys, whenever } from "@vueuse/core"
|
||||
import Editors from "../components/editor/Editors.vue"
|
||||
import Preview from "../components/editor/Preview.vue"
|
||||
@@ -29,6 +30,12 @@ import TaskPanel from "../components/task/TaskPanel.vue"
|
||||
import { show, panelSize } from "../store/panel"
|
||||
import { html, css, js } from "../store/editors"
|
||||
import { assetBaseUrl, taskId } from "../store/task"
|
||||
import { saveTaskCode } from "../composables/useTaskCode"
|
||||
|
||||
// 每一节教程的代码各存一份;任务 id 和代码一起看,切换小节时不会把新一节的代码存到上一节下面
|
||||
watch([taskId, html, css, js], ([id]) => {
|
||||
if (id) saveTaskCode(id, { html: html.value, css: css.value, js: js.value })
|
||||
})
|
||||
|
||||
const { ctrl_s } = useMagicKeys({
|
||||
passive: false,
|
||||
|
||||
Reference in new issue
Block a user